Compare commits
55 Commits
2.13.0
...
debug-api-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3856503f | ||
|
|
0b6362a4bd | ||
|
|
45b300eaff | ||
|
|
ad7545f009 | ||
|
|
5df30b9eb0 | ||
|
|
2e0555dbca | ||
|
|
8bbb6e5a6e | ||
|
|
ea71ce44fa | ||
|
|
a3f2b4b0af | ||
|
|
9650aa56c7 | ||
|
|
0beb0d95c1 | ||
|
|
f4c7896046 | ||
|
|
b1272b9da3 | ||
|
|
943c0a6256 | ||
|
|
3de585fe17 | ||
|
|
d245e196c1 | ||
|
|
d6abf03d42 | ||
|
|
f98585d832 | ||
|
|
bc3e973830 | ||
|
|
c732ca2d2f | ||
|
|
d4c2ad4a57 | ||
|
|
0292523855 | ||
|
|
044d756626 | ||
|
|
a9c0c5f835 | ||
|
|
2b0a519c36 | ||
|
|
871da94da0 | ||
|
|
81faf20f20 | ||
|
|
b8757ac8eb | ||
|
|
b927e08d5e | ||
|
|
bf59ef50a3 | ||
|
|
840a3ce732 | ||
|
|
f7780cecb3 | ||
|
|
e2188edc9d | ||
|
|
97d2a3bdf3 | ||
|
|
24c61034c1 | ||
|
|
95b3fff917 | ||
|
|
0f52188261 | ||
|
|
b1b0a76465 | ||
|
|
8a6024ce9b | ||
|
|
61a3bfe994 | ||
|
|
842044e759 | ||
|
|
b3e035d353 | ||
|
|
33f433ce45 | ||
|
|
abb79ccbeb | ||
|
|
c340b62f43 | ||
|
|
bbb096412d | ||
|
|
141a530e28 | ||
|
|
d08b498cb9 | ||
|
|
bebee78152 | ||
|
|
5b77edb76d | ||
|
|
bcec6a8915 | ||
|
|
3496d5f00b | ||
|
|
4ee5ae90e7 | ||
|
|
4180e41fa1 | ||
|
|
5289e4d66b |
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -34,3 +34,5 @@ jobs:
|
||||
prettier_dir: app/
|
||||
gofmt: true
|
||||
gofmt_dir: api/
|
||||
- name: Typecheck
|
||||
uses: icrawl/action-tsc@v1
|
||||
|
||||
230
.github/workflows/nightly-security-scan.yml
vendored
Normal file
230
.github/workflows/nightly-security-scan.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
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
|
||||
233
.github/workflows/pr-security.yml
vendored
Normal file
233
.github/workflows/pr-security.yml
vendored
Normal file
@@ -0,0 +1,233 @@
|
||||
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
15
.github/workflows/test-client.yaml
vendored
@@ -1,15 +0,0 @@
|
||||
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
|
||||
29
.github/workflows/test.yaml
vendored
Normal file
29
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
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 ./...
|
||||
@@ -16,6 +16,9 @@ module.exports = {
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
postcssLoaderOptions: {
|
||||
implementation: require('postcss'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -9,7 +9,18 @@ import (
|
||||
// CreateServerTLSConfiguration creates a basic tls.Config to be used by servers with recommended TLS settings
|
||||
func CreateServerTLSConfiguration() *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_AES_128_GCM_SHA256,
|
||||
tls.TLS_AES_256_GCM_SHA384,
|
||||
tls.TLS_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -83,6 +84,15 @@ 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)
|
||||
|
||||
@@ -97,6 +97,7 @@ 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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
@@ -13,6 +15,30 @@ 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 {
|
||||
@@ -33,6 +59,18 @@ 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)
|
||||
@@ -40,12 +78,24 @@ 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 {
|
||||
return service.connection.UpdateObject(BucketName, []byte(settingsKey), settings)
|
||||
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
|
||||
}
|
||||
|
||||
func (service *Service) IsFeatureFlagEnabled(feature portainer.Feature) bool {
|
||||
@@ -61,3 +111,9 @@ func (service *Service) IsFeatureFlagEnabled(feature portainer.Feature) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (service *Service) InvalidateCache() {
|
||||
service.mu.Lock()
|
||||
service.cache = nil
|
||||
service.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -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]bool{},
|
||||
EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{},
|
||||
}
|
||||
|
||||
store.EndpointRelation().Create(relation)
|
||||
|
||||
@@ -31,6 +31,8 @@ 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,
|
||||
|
||||
@@ -97,6 +97,9 @@ func (m *Migrator) Migrate() error {
|
||||
newMigration(35, m.migrateDBVersionToDB35),
|
||||
|
||||
newMigration(36, m.migrateDBVersionToDB36),
|
||||
|
||||
// Portainer 2.13
|
||||
newMigration(40, m.migrateDBVersionToDB40),
|
||||
}
|
||||
|
||||
var lastDbVersion int
|
||||
|
||||
@@ -54,7 +54,7 @@ func (m *Migrator) updateEndpointsAndEndpointGroupsToDBVersion23() error {
|
||||
|
||||
relation := &portainer.EndpointRelation{
|
||||
EndpointID: endpoint.ID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||
EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{},
|
||||
}
|
||||
|
||||
err = m.endpointRelationService.Create(relation)
|
||||
|
||||
31
api/datastore/migrator/migrate_dbversion40.go
Normal file
31
api/datastore/migrator/migrate_dbversion40.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package migrator
|
||||
|
||||
import "github.com/portainer/portainer/api/internal/endpointutils"
|
||||
|
||||
func (m *Migrator) migrateDBVersionToDB40() error {
|
||||
if err := m.trustCurrentEdgeEndpointsDB40(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) trustCurrentEdgeEndpointsDB40() error {
|
||||
migrateLog.Info("- trusting current edge endpoints")
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
endpoint.UserTrusted = true
|
||||
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
|
||||
github.com/docker/cli v20.10.9+incompatible
|
||||
github.com/docker/docker v20.10.9+incompatible
|
||||
github.com/fvbommel/sortorder v1.0.2
|
||||
github.com/fxamacker/cbor/v2 v2.3.0
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||
github.com/go-git/go-git/v5 v5.3.0
|
||||
@@ -32,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-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
|
||||
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
|
||||
|
||||
@@ -376,6 +376,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
|
||||
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
|
||||
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
||||
github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo=
|
||||
github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
|
||||
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
|
||||
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
|
||||
@@ -809,6 +811,8 @@ 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=
|
||||
|
||||
@@ -164,7 +164,9 @@ func (handler *Handler) updateEndpoint(endpointID portainer.EndpointID) error {
|
||||
edgeStackSet[edgeStackID] = true
|
||||
}
|
||||
|
||||
relation.EdgeStacks = edgeStackSet
|
||||
for edgeStackID := range edgeStackSet {
|
||||
relation.EdgeStacks[edgeStackID] = portainer.EdgeStackStatus{}
|
||||
}
|
||||
|
||||
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -228,7 +227,6 @@ 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,
|
||||
}
|
||||
@@ -337,7 +335,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -411,7 +408,7 @@ func updateEndpointRelations(endpointRelationService dataservices.EndpointRelati
|
||||
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
||||
}
|
||||
|
||||
relation.EdgeStacks[edgeStackID] = true
|
||||
relation.EdgeStacks[edgeStackID] = portainer.EdgeStackStatus{}
|
||||
|
||||
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
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]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{}},
|
||||
{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{}},
|
||||
}
|
||||
|
||||
relatedIds := []portainer.EndpointID{2, 3}
|
||||
@@ -36,3 +29,4 @@ func Test_updateEndpointRelation_successfulRuns(t *testing.T) {
|
||||
assert.Equal(t, shouldBeRelated, relation.EdgeStacks[edgeStackID])
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// @id EdgeStackList
|
||||
@@ -25,5 +26,35 @@ 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}
|
||||
}
|
||||
|
||||
return response.JSON(w, edgeStacks)
|
||||
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)
|
||||
}
|
||||
|
||||
57
api/http/handler/edgestacks/edgestack_status_delete.go
Normal file
57
api/http/handler/edgestacks/edgestack_status_delete.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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)
|
||||
}
|
||||
@@ -49,13 +49,6 @@ 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 {
|
||||
@@ -74,17 +67,28 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err}
|
||||
}
|
||||
|
||||
stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{
|
||||
Type: *payload.Status,
|
||||
Error: payload.Error,
|
||||
EndpointID: *payload.EndpointID,
|
||||
endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment relations", err}
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||
endpointRelation.EdgeStacks[portainer.EdgeStackID(stackID)] = portainer.EdgeStackStatus{
|
||||
Type: *payload.Status,
|
||||
Error: payload.Error,
|
||||
}
|
||||
|
||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ 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"
|
||||
@@ -118,7 +119,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] = true
|
||||
relation.EdgeStacks[stack.ID] = portainer.EdgeStackStatus{}
|
||||
|
||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
||||
if err != nil {
|
||||
@@ -180,7 +181,6 @@ 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)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -24,10 +25,11 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *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)
|
||||
@@ -43,6 +45,12 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
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)
|
||||
}
|
||||
@@ -35,11 +35,12 @@ func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, en
|
||||
}
|
||||
|
||||
endpointStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks)
|
||||
stacksSet := map[portainer.EdgeStackID]bool{}
|
||||
updatedStacks := make(map[portainer.EdgeStackID]portainer.EdgeStackStatus)
|
||||
|
||||
for _, edgeStackID := range endpointStacks {
|
||||
stacksSet[edgeStackID] = true
|
||||
updatedStacks[edgeStackID] = endpointRelation.EdgeStacks[edgeStackID]
|
||||
}
|
||||
endpointRelation.EdgeStacks = stacksSet
|
||||
endpointRelation.EdgeStacks = updatedStacks
|
||||
|
||||
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
||||
}
|
||||
|
||||
@@ -209,13 +209,15 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *
|
||||
|
||||
relationObject := &portainer.EndpointRelation{
|
||||
EndpointID: endpoint.ID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||
EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{},
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
relatedEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks)
|
||||
for _, stackID := range relatedEdgeStacks {
|
||||
relationObject.EdgeStacks[stackID] = true
|
||||
relationObject.EdgeStacks[stackID] = portainer.EdgeStackStatus{
|
||||
Type: portainer.StatusAcknowledged,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,17 +301,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,
|
||||
@@ -317,12 +319,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,
|
||||
@@ -343,7 +345,15 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
|
||||
endpoint.EdgeID = edgeID.String()
|
||||
}
|
||||
|
||||
err = handler.saveEndpointAndUpdateAuthorizations(endpoint)
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the environment", err}
|
||||
}
|
||||
@@ -511,6 +521,42 @@ 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))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -21,6 +22,9 @@ type endpointCreateGlobalKeyResponse struct {
|
||||
// @router /endpoints/global-key [post]
|
||||
func (handler *Handler) endpointCreateGlobalKey(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
edgeID := r.Header.Get(portainer.PortainerAgentEdgeIDHeader)
|
||||
if edgeID == "" {
|
||||
return httperror.BadRequest("Invalid Edge ID", errors.New("the Edge ID cannot be empty"))
|
||||
}
|
||||
|
||||
// Search for existing endpoints for the given edgeID
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
)
|
||||
|
||||
func TestEmptyGlobalKey(t *testing.T) {
|
||||
handler := NewHandler(
|
||||
helper.NewTestRequestBouncer(),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://portainer.io:9443/endpoints/global-key", nil)
|
||||
if err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatal("expected a 400 response, found:", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -87,22 +87,6 @@ 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}
|
||||
|
||||
@@ -2,6 +2,7 @@ package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,14 +13,24 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
EdgeDeviceFilterAll = "all"
|
||||
EdgeDeviceFilterTrusted = "trusted"
|
||||
EdgeDeviceFilterUntrusted = "untrusted"
|
||||
EdgeDeviceFilterNone = "none"
|
||||
)
|
||||
|
||||
const (
|
||||
EdgeDeviceIntervalMultiplier = 2
|
||||
EdgeDeviceIntervalAdd = 20
|
||||
)
|
||||
|
||||
var endpointGroupNames map[portainer.EndpointGroupID]string
|
||||
|
||||
// @id EndpointList
|
||||
// @summary List environments(endpoints)
|
||||
// @description List all environments(endpoints) based on the current user authorizations. Will
|
||||
@@ -38,7 +49,7 @@ const (
|
||||
// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)"
|
||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||
// @param edgeDeviceFilter query string false "will return only these edge devices" Enum("all", "trusted", "untrusted")
|
||||
// @param edgeDeviceFilter query string false "will return only these edge environments, none will return only regular edge environments" Enum("all", "trusted", "untrusted", "none")
|
||||
// @success 200 {array} portainer.Endpoint "Endpoints"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints [get]
|
||||
@@ -55,6 +66,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||
|
||||
var endpointTypes []int
|
||||
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
|
||||
@@ -67,11 +80,23 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
var endpointIDs []portainer.EndpointID
|
||||
request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true)
|
||||
|
||||
var statuses []int
|
||||
request.RetrieveJSONQueryParameter(r, "status", &statuses, true)
|
||||
|
||||
var groupIDs []int
|
||||
request.RetrieveJSONQueryParameter(r, "groupIds", &groupIDs, true)
|
||||
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err}
|
||||
}
|
||||
|
||||
// create endpoint groups as a map for more convenient access
|
||||
endpointGroupNames = make(map[portainer.EndpointGroupID]string, 0)
|
||||
for _, group := range endpointGroups {
|
||||
endpointGroupNames[group.ID] = group.Name
|
||||
}
|
||||
|
||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err}
|
||||
@@ -90,12 +115,16 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
||||
totalAvailableEndpoints := len(filteredEndpoints)
|
||||
|
||||
if groupID != 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, []int{groupID})
|
||||
}
|
||||
|
||||
if endpointIDs != nil {
|
||||
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
|
||||
}
|
||||
|
||||
if groupID != 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
|
||||
if len(groupIDs) > 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, groupIDs)
|
||||
}
|
||||
|
||||
edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false)
|
||||
@@ -103,6 +132,10 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
||||
}
|
||||
|
||||
if len(statuses) > 0 {
|
||||
filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, statuses, settings)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
tags, err := handler.DataStore.Tag().Tags()
|
||||
if err != nil {
|
||||
@@ -123,6 +156,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch)
|
||||
}
|
||||
|
||||
// Sort endpoints by field
|
||||
sortEndpointsByField(filteredEndpoints, sortField, sortOrder == "desc")
|
||||
|
||||
filteredEndpointCount := len(filteredEndpoints)
|
||||
|
||||
paginatedEndpoints := paginateEndpoints(filteredEndpoints, start, limit)
|
||||
@@ -160,11 +196,11 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
|
||||
return endpoints[start:end]
|
||||
}
|
||||
|
||||
func filterEndpointsByGroupID(endpoints []portainer.Endpoint, endpointGroupID portainer.EndpointGroupID) []portainer.Endpoint {
|
||||
func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []int) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.GroupID == endpointGroupID {
|
||||
if utils.Contains(endpointGroupIDs, int(endpoint.GroupID)) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
@@ -190,6 +226,64 @@ func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGro
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []int, settings *portainer.Settings) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
status := endpoint.Status
|
||||
if endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
isCheckValid := false
|
||||
edgeCheckinInterval := endpoint.EdgeCheckinInterval
|
||||
if endpoint.EdgeCheckinInterval == 0 {
|
||||
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
|
||||
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
|
||||
}
|
||||
status = portainer.EndpointStatusDown // Offline
|
||||
if isCheckValid {
|
||||
status = portainer.EndpointStatusUp // Online
|
||||
}
|
||||
}
|
||||
|
||||
if utils.Contains(statuses, int(status)) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func sortEndpointsByField(endpoints []portainer.Endpoint, sortField string, isSortDesc bool) {
|
||||
|
||||
switch sortField {
|
||||
case "Name":
|
||||
if isSortDesc {
|
||||
sort.Stable(sort.Reverse(EndpointsByName(endpoints)))
|
||||
} else {
|
||||
sort.Stable(EndpointsByName(endpoints))
|
||||
}
|
||||
|
||||
case "Group":
|
||||
if isSortDesc {
|
||||
sort.Stable(sort.Reverse(EndpointsByGroup(endpoints)))
|
||||
} else {
|
||||
sort.Stable(EndpointsByGroup(endpoints))
|
||||
}
|
||||
|
||||
case "Status":
|
||||
if isSortDesc {
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
return endpoints[i].Status > endpoints[j].Status
|
||||
})
|
||||
} else {
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
return endpoints[i].Status < endpoints[j].Status
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
|
||||
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
|
||||
return true
|
||||
@@ -250,10 +344,6 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int)
|
||||
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter string) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
if edgeDeviceFilter != EdgeDeviceFilterAll && edgeDeviceFilter != EdgeDeviceFilterTrusted && edgeDeviceFilter != EdgeDeviceFilterUntrusted {
|
||||
return endpoints
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if shouldReturnEdgeDevice(endpoint, edgeDeviceFilter) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
@@ -263,7 +353,12 @@ func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilte
|
||||
}
|
||||
|
||||
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceFilter string) bool {
|
||||
if !endpoint.IsEdgeDevice {
|
||||
// none - return all endpoints that are not edge devices
|
||||
if edgeDeviceFilter == EdgeDeviceFilterNone && !endpoint.IsEdgeDevice {
|
||||
return true
|
||||
}
|
||||
|
||||
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -17,27 +17,36 @@ import (
|
||||
)
|
||||
|
||||
type endpointListEdgeDeviceTest struct {
|
||||
title string
|
||||
expected []portainer.EndpointID
|
||||
filter string
|
||||
}
|
||||
|
||||
func Test_endpointList(t *testing.T) {
|
||||
var err error
|
||||
is := assert.New(t)
|
||||
|
||||
_, store, teardown := datastore.MustNewTestStore(true, true)
|
||||
defer teardown()
|
||||
|
||||
trustedEndpoint := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1}
|
||||
err := store.Endpoint().Create(&trustedEndpoint)
|
||||
is.NoError(err, "error creating environment")
|
||||
trustedEndpoint := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
untrustedEndpoint := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularEndpoint := portainer.Endpoint{ID: 5, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.DockerEnvironment}
|
||||
|
||||
untrustedEndpoint := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1}
|
||||
err = store.Endpoint().Create(&untrustedEndpoint)
|
||||
is.NoError(err, "error creating environment")
|
||||
endpoints := []portainer.Endpoint{
|
||||
trustedEndpoint,
|
||||
untrustedEndpoint,
|
||||
regularUntrustedEdgeEndpoint,
|
||||
regularTrustedEdgeEndpoint,
|
||||
regularEndpoint,
|
||||
}
|
||||
|
||||
regularEndpoint := portainer.Endpoint{ID: 3, IsEdgeDevice: false, GroupID: 1}
|
||||
err = store.Endpoint().Create(®ularEndpoint)
|
||||
is.NoError(err, "error creating environment")
|
||||
for _, endpoint := range endpoints {
|
||||
err = store.Endpoint().Create(&endpoint)
|
||||
is.NoError(err, "error creating environment")
|
||||
}
|
||||
|
||||
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "error creating a user")
|
||||
@@ -49,33 +58,45 @@ func Test_endpointList(t *testing.T) {
|
||||
|
||||
tests := []endpointListEdgeDeviceTest{
|
||||
{
|
||||
[]portainer.EndpointID{trustedEndpoint.ID, untrustedEndpoint.ID},
|
||||
"should show all edge endpoints",
|
||||
[]portainer.EndpointID{trustedEndpoint.ID, untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterAll,
|
||||
},
|
||||
{
|
||||
[]portainer.EndpointID{trustedEndpoint.ID},
|
||||
"should show only trusted edge devices",
|
||||
[]portainer.EndpointID{trustedEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterTrusted,
|
||||
},
|
||||
{
|
||||
[]portainer.EndpointID{untrustedEndpoint.ID},
|
||||
"should show only untrusted edge devices",
|
||||
[]portainer.EndpointID{untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterUntrusted,
|
||||
},
|
||||
{
|
||||
"should show no edge devices",
|
||||
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
req := buildEndpointListRequest(test.filter)
|
||||
resp, err := doEndpointListRequest(req, h, is)
|
||||
is.NoError(err)
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
is.Equal(len(test.expected), len(resp))
|
||||
req := buildEndpointListRequest(test.filter)
|
||||
resp, err := doEndpointListRequest(req, h, is)
|
||||
is.NoError(err)
|
||||
|
||||
respIds := []portainer.EndpointID{}
|
||||
is.Equal(len(test.expected), len(resp))
|
||||
|
||||
for _, endpoint := range resp {
|
||||
respIds = append(respIds, endpoint.ID)
|
||||
}
|
||||
respIds := []portainer.EndpointID{}
|
||||
|
||||
is.Equal(test.expected, respIds, "response should contain all edge devices")
|
||||
for _, endpoint := range resp {
|
||||
respIds = append(respIds, endpoint.ID)
|
||||
}
|
||||
|
||||
is.ElementsMatch(test.expected, respIds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,9 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
currentEdgeStackSet[edgeStackID] = true
|
||||
}
|
||||
|
||||
relation.EdgeStacks = currentEdgeStackSet
|
||||
for edgeStackID := range currentEdgeStackSet {
|
||||
relation.EdgeStacks[edgeStackID] = portainer.EdgeStackStatus{}
|
||||
}
|
||||
|
||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
||||
if err != nil {
|
||||
|
||||
43
api/http/handler/endpoints/sort.go
Normal file
43
api/http/handler/endpoints/sort.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fvbommel/sortorder"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type EndpointsByName []portainer.Endpoint
|
||||
|
||||
func (e EndpointsByName) Len() int {
|
||||
return len(e)
|
||||
}
|
||||
|
||||
func (e EndpointsByName) Swap(i, j int) {
|
||||
e[i], e[j] = e[j], e[i]
|
||||
}
|
||||
|
||||
func (e EndpointsByName) Less(i, j int) bool {
|
||||
return sortorder.NaturalLess(strings.ToLower(e[i].Name), strings.ToLower(e[j].Name))
|
||||
}
|
||||
|
||||
type EndpointsByGroup []portainer.Endpoint
|
||||
|
||||
func (e EndpointsByGroup) Len() int {
|
||||
return len(e)
|
||||
}
|
||||
|
||||
func (e EndpointsByGroup) Swap(i, j int) {
|
||||
e[i], e[j] = e[j], e[i]
|
||||
}
|
||||
|
||||
func (e EndpointsByGroup) Less(i, j int) bool {
|
||||
if e[i].GroupID == e[j].GroupID {
|
||||
return false
|
||||
}
|
||||
|
||||
groupA := endpointGroupNames[e[i].GroupID]
|
||||
groupB := endpointGroupNames[e[j].GroupID]
|
||||
|
||||
return sortorder.NaturalLess(strings.ToLower(groupA), strings.ToLower(groupB))
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/http/handler/auth"
|
||||
@@ -154,9 +156,20 @@ 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"):
|
||||
|
||||
@@ -77,7 +77,7 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
if payload.EdgePortainerURL != nil {
|
||||
if payload.EdgePortainerURL != nil && *payload.EdgePortainerURL != "" {
|
||||
_, err := edge.ParseHostForEdge(*payload.EdgePortainerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -126,11 +126,12 @@ func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edg
|
||||
}
|
||||
|
||||
endpointStacks := edge.EndpointRelatedEdgeStacks(&endpoint, endpointGroup, edgeGroups, edgeStacks)
|
||||
stacksSet := map[portainer.EdgeStackID]bool{}
|
||||
updatedStacks := make(map[portainer.EdgeStackID]portainer.EdgeStackStatus)
|
||||
|
||||
for _, edgeStackID := range endpointStacks {
|
||||
stacksSet[edgeStackID] = true
|
||||
updatedStacks[edgeStackID] = endpointRelation.EdgeStacks[edgeStackID]
|
||||
}
|
||||
endpointRelation.EdgeStacks = stacksSet
|
||||
endpointRelation.EdgeStacks = updatedStacks
|
||||
|
||||
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
|
||||
}
|
||||
|
||||
if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier {
|
||||
return errors.New("invalid Edge identifier")
|
||||
return errors.New(fmt.Sprintf("invalid Edge identifier for endpoint %d. Expecting: %s - Received: %s", endpoint.ID, endpoint.EdgeID, edgeIdentifier))
|
||||
}
|
||||
|
||||
if endpoint.LastCheckInDate > 0 || endpoint.UserTrusted {
|
||||
|
||||
@@ -139,8 +139,7 @@ func (server *Server) Start() error {
|
||||
edgeJobsHandler.FileService = server.FileService
|
||||
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer)
|
||||
edgeStacksHandler.DataStore = server.DataStore
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore)
|
||||
edgeStacksHandler.FileService = server.FileService
|
||||
edgeStacksHandler.GitService = server.GitService
|
||||
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||
|
||||
@@ -19,6 +19,10 @@ func ParseHostForEdge(portainerURL string) (string, error) {
|
||||
portainerHost = parsedURL.Host
|
||||
}
|
||||
|
||||
if portainerHost == "" {
|
||||
return "", errors.New("hostname cannot be empty")
|
||||
}
|
||||
|
||||
if portainerHost == "localhost" {
|
||||
return "", errors.New("cannot use localhost as environment URL")
|
||||
}
|
||||
|
||||
@@ -235,6 +235,13 @@ 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 {
|
||||
|
||||
11
api/internal/utils/utils.go
Normal file
11
api/internal/utils/utils.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package utils
|
||||
|
||||
// Contains returns true if the given int is contained in the given slice of int.
|
||||
func Contains(s []int, e int) bool {
|
||||
for _, a := range s {
|
||||
if a == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package jwt
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/gorilla/securecookie"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// scope represents JWT scopes that are supported in JWT claims.
|
||||
@@ -164,6 +166,12 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
|
||||
return "", fmt.Errorf("invalid scope: %v", scope)
|
||||
}
|
||||
|
||||
if _, ok := os.LookupEnv("DOCKER_EXTENSION"); ok {
|
||||
// Set expiration to 99 years for docker desktop extension.
|
||||
log.Infof("[message: detected docker desktop extension mode]")
|
||||
expiresAt = time.Now().Add(time.Hour * 8760 * 99).Unix()
|
||||
}
|
||||
|
||||
cl := claims{
|
||||
UserID: int(data.ID),
|
||||
Username: data.Username,
|
||||
|
||||
@@ -252,14 +252,13 @@ type (
|
||||
//EdgeStack represents an edge stack
|
||||
EdgeStack struct {
|
||||
// EdgeStack Identifier
|
||||
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"`
|
||||
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"`
|
||||
ManifestPath string
|
||||
DeploymentType EdgeStackDeploymentType
|
||||
|
||||
@@ -274,9 +273,8 @@ type (
|
||||
|
||||
//EdgeStackStatus represents an edge stack status
|
||||
EdgeStackStatus struct {
|
||||
Type EdgeStackStatusType `json:"Type"`
|
||||
Error string `json:"Error"`
|
||||
EndpointID EndpointID `json:"EndpointID"`
|
||||
Type EdgeStackStatusType `json:"Type"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
|
||||
//EdgeStackStatusType represents an edge stack status type
|
||||
@@ -415,7 +413,7 @@ type (
|
||||
// EndpointRelation represents a environment(endpoint) relation object
|
||||
EndpointRelation struct {
|
||||
EndpointID EndpointID
|
||||
EdgeStacks map[EdgeStackID]bool
|
||||
EdgeStacks map[EdgeStackID]EdgeStackStatus
|
||||
}
|
||||
|
||||
// Extension represents a deprecated Portainer extension
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#page-wrapper,
|
||||
@@ -812,75 +816,6 @@ 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;
|
||||
}
|
||||
@@ -889,54 +824,6 @@ 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;
|
||||
@@ -947,10 +834,6 @@ 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;
|
||||
|
||||
@@ -161,6 +161,7 @@ html {
|
||||
--bg-app-datatable-tbody: var(--grey-24);
|
||||
--bg-stepper-item-active: var(--white-color);
|
||||
--bg-stepper-item-counter: var(--grey-61);
|
||||
--bg-sortbutton-color: var(--white-color);
|
||||
|
||||
--text-main-color: var(--grey-7);
|
||||
--text-body-color: var(--grey-6);
|
||||
@@ -242,6 +243,7 @@ html {
|
||||
--border-daterangepicker-after: var(--white-color);
|
||||
--border-tooltip-color: var(--grey-47);
|
||||
--border-modal: 0px;
|
||||
--border-sortbutton: var(--grey-8);
|
||||
|
||||
--hover-sidebar-color: var(--grey-37);
|
||||
--shadow-box-color: 0 3px 10px -2px var(--grey-50);
|
||||
@@ -334,6 +336,7 @@ html {
|
||||
--bg-app-datatable-tbody: var(--grey-1);
|
||||
--bg-stepper-item-active: var(--grey-1);
|
||||
--bg-stepper-item-counter: var(--grey-7);
|
||||
--bg-sortbutton-color: var(--grey-1);
|
||||
|
||||
--text-main-color: var(--white-color);
|
||||
--text-body-color: var(--white-color);
|
||||
@@ -416,6 +419,7 @@ html {
|
||||
--border-daterangepicker-after: var(--grey-3);
|
||||
--border-tooltip-color: var(--grey-3);
|
||||
--border-modal: 0px;
|
||||
--border-sortbutton: var(--grey-3);
|
||||
|
||||
--hover-sidebar-color: var(--grey-3);
|
||||
--blue-color: var(--blue-2);
|
||||
@@ -507,6 +511,7 @@ html {
|
||||
--bg-app-datatable-tbody: var(--black-color);
|
||||
--bg-stepper-item-active: var(--black-color);
|
||||
--bg-stepper-item-counter: var(--grey-3);
|
||||
--bg-sortbutton-color: var(--grey-1);
|
||||
|
||||
--text-main-color: var(--white-color);
|
||||
--text-body-color: var(--white-color);
|
||||
@@ -578,6 +583,7 @@ html {
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--border-modal: 1px solid var(--white-color);
|
||||
--border-blocklist-color: var(--white-color);
|
||||
--border-sortbutton: var(--black-color);
|
||||
|
||||
--hover-sidebar-color: var(--blue-9);
|
||||
--hover-sidebar-color: var(--black-color);
|
||||
|
||||
@@ -3,8 +3,9 @@ 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]).config([
|
||||
angular.module('portainer.docker', ['portainer.app', containersModule, componentsModule, networksModule]).config([
|
||||
'$stateRegistryProvider',
|
||||
function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
@@ -323,8 +324,7 @@ angular.module('portainer.docker', ['portainer.app', containersModule, component
|
||||
url: '/:id?nodeName',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/networks/edit/network.html',
|
||||
controller: 'NetworkController',
|
||||
component: 'networkDetailsView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,10 +2,16 @@ 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
|
||||
@@ -86,15 +92,37 @@ 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
|
||||
) {
|
||||
const url = `/endpoints/${endpointId}/docker/containers/${id}`;
|
||||
let url = `/endpoints/${endpointId}/docker/containers`;
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
return `${url}/${action}`;
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
|
||||
18
app/docker/containers/queries.ts
Normal file
18
app/docker/containers/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
52
app/docker/networks/edit/NetworkContainersTable.test.tsx
Normal file
52
app/docker/networks/edit/NetworkContainersTable.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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();
|
||||
});
|
||||
97
app/docker/networks/edit/NetworkContainersTable.tsx
Normal file
97
app/docker/networks/edit/NetworkContainersTable.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
123
app/docker/networks/edit/NetworkDetailsTable.test.tsx
Normal file
123
app/docker/networks/edit/NetworkDetailsTable.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
119
app/docker/networks/edit/NetworkDetailsTable.tsx
Normal file
119
app/docker/networks/edit/NetworkDetailsTable.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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(' - ')}`
|
||||
: '';
|
||||
}
|
||||
}
|
||||
130
app/docker/networks/edit/NetworkDetailsView.tsx
Normal file
130
app/docker/networks/edit/NetworkDetailsView.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
34
app/docker/networks/edit/NetworkOptionsTable.test.tsx
Normal file
34
app/docker/networks/edit/NetworkOptionsTable.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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();
|
||||
});
|
||||
35
app/docker/networks/edit/NetworkOptionsTable.tsx
Normal file
35
app/docker/networks/edit/NetworkOptionsTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
5
app/docker/networks/edit/index.ts
Normal file
5
app/docker/networks/edit/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { react2angular } from '@/react-tools/react2angular';
|
||||
|
||||
import { NetworkDetailsView } from './NetworkDetailsView';
|
||||
|
||||
export const NetworkDetailsViewAngular = react2angular(NetworkDetailsView, []);
|
||||
7
app/docker/networks/index.ts
Normal file
7
app/docker/networks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { NetworkDetailsViewAngular } from './edit';
|
||||
|
||||
export const networksModule = angular
|
||||
.module('portainer.docker.networks', [])
|
||||
.component('networkDetailsView', NetworkDetailsViewAngular).name;
|
||||
5
app/docker/networks/network.helper.ts
Normal file
5
app/docker/networks/network.helper.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const systemNetworks = ['host', 'bridge', 'none'];
|
||||
|
||||
export function isSystemNetwork(networkName: string) {
|
||||
return systemNetworks.includes(networkName);
|
||||
}
|
||||
71
app/docker/networks/network.service.ts
Normal file
71
app/docker/networks/network.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
}
|
||||
83
app/docker/networks/queries.ts
Normal file
83
app/docker/networks/queries.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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'
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
50
app/docker/networks/types.ts
Normal file
50
app/docker/networks/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title-text="Network details"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="docker.networks">Networks</a> > <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>
|
||||
@@ -1,120 +0,0 @@
|
||||
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();
|
||||
},
|
||||
]);
|
||||
94
app/edge/components/EdgeCheckInIntervalField.tsx
Normal file
94
app/edge/components/EdgeCheckInIntervalField.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { FormControl } from '@/portainer/components/form-components/FormControl'
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||
|
||||
import { OsSelector } from './OsSelector';
|
||||
import { EdgeProperties } from './types';
|
||||
@@ -27,19 +28,26 @@ export function EdgePropertiesForm({
|
||||
/>
|
||||
|
||||
{!hideIdGetter && (
|
||||
<FormControl
|
||||
label="Edge ID Generator"
|
||||
tooltip="A bash script one liner that will generate the edge id"
|
||||
inputId="edge-id-generator-input"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
name="edgeIdGenerator"
|
||||
value={values.edgeIdGenerator}
|
||||
id="edge-id-generator-input"
|
||||
onChange={(e) => setFieldValue(e.target.name, e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<>
|
||||
<FormControl
|
||||
label="Edge ID Generator"
|
||||
tooltip="A bash script one liner that will generate the edge id and will be assigned to the PORTAINER_EDGE_ID environment variable"
|
||||
inputId="edge-id-generator-input"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
name="edgeIdGenerator"
|
||||
value={values.edgeIdGenerator}
|
||||
id="edge-id-generator-input"
|
||||
onChange={(e) => setFieldValue(e.target.name, e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<TextTip color="blue">
|
||||
<code>PORTAINER_EDGE_ID</code> environment variable is required to
|
||||
successfully connect the edge agent to Portainer
|
||||
</TextTip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { r2a } from '@/react-tools/react2angular';
|
||||
import { useSettings } from '@/portainer/settings/settings.service';
|
||||
|
||||
import { EdgePropertiesForm } from './EdgePropertiesForm';
|
||||
import { Scripts } from './Scripts';
|
||||
import { ScriptTabs } from './ScriptTabs';
|
||||
import { EdgeProperties } from './types';
|
||||
|
||||
interface Props {
|
||||
@@ -43,7 +43,7 @@ export function EdgeScriptForm({ edgeKey, edgeId }: Props) {
|
||||
hideIdGetter={edgeId !== undefined}
|
||||
/>
|
||||
|
||||
<Scripts
|
||||
<ScriptTabs
|
||||
values={edgeProperties}
|
||||
agentVersion={agentVersion}
|
||||
edgeKey={edgeKey}
|
||||
|
||||
@@ -49,7 +49,7 @@ interface Props {
|
||||
onPlatformChange(platform: Platform): void;
|
||||
}
|
||||
|
||||
export function Scripts({
|
||||
export function ScriptTabs({
|
||||
agentVersion,
|
||||
values,
|
||||
edgeKey,
|
||||
@@ -134,7 +134,7 @@ function buildLinuxStandaloneCommand(
|
||||
)
|
||||
);
|
||||
|
||||
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}
|
||||
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}\
|
||||
docker run -d \\
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \\
|
||||
-v /var/lib/docker/volumes:/var/lib/docker/volumes \\
|
||||
@@ -168,7 +168,7 @@ function buildWindowsStandaloneCommand(
|
||||
|
||||
return `${
|
||||
edgeIdScript ? `$Env:PORTAINER_EDGE_ID = "@(${edgeIdScript})" \n\n` : ''
|
||||
}
|
||||
}\
|
||||
docker run -d \\
|
||||
--mount type=npipe,src=\\\\.\\pipe\\docker_engine,dst=\\\\.\\pipe\\docker_engine \\
|
||||
--mount type=bind,src=C:\\ProgramData\\docker\\volumes,dst=C:\\ProgramData\\docker\\volumes \\
|
||||
@@ -199,7 +199,7 @@ function buildLinuxSwarmCommand(
|
||||
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
|
||||
]);
|
||||
|
||||
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}
|
||||
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}\
|
||||
docker network create \\
|
||||
--driver overlay \\
|
||||
portainer_agent_network;
|
||||
@@ -270,13 +270,11 @@ function buildKubernetesCommand(
|
||||
const idEnvVar = edgeIdScript
|
||||
? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n`
|
||||
: '';
|
||||
const envVarsTrimmed = envVars.trim();
|
||||
const edgeIdVar = !edgeIdScript && edgeId ? edgeId : '$PORTAINER_EDGE_ID';
|
||||
const selfSigned = allowSelfSignedCerts ? '1' : '0';
|
||||
|
||||
return `${idEnvVar}curl https://downloads.portainer.io/ce${agentShortVersion}/portainer-edge-agent-setup.sh |
|
||||
bash -s -- "${edgeIdVar}" \\
|
||||
"${edgeKey}" \\
|
||||
"${selfSigned}" "${agentSecret}" "${envVars}"`;
|
||||
return `${idEnvVar}curl https://downloads.portainer.io/ce${agentShortVersion}/portainer-edge-agent-setup.sh | bash -s -- "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${agentSecret}" "${envVarsTrimmed}"`;
|
||||
}
|
||||
|
||||
function buildDefaultEnvVars(
|
||||
@@ -1,4 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
export class EdgeGroupFormController {
|
||||
/* @ngInject */
|
||||
@@ -17,6 +18,7 @@ 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);
|
||||
@@ -39,6 +41,29 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
|
||||
@@ -10,7 +10,12 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
|
||||
this.getEnvironments = function () {
|
||||
return $async(async () => {
|
||||
try {
|
||||
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { edgeDeviceFilter: 'trusted' }), GroupService.groups()]);
|
||||
const [endpointsResponse, groups] = await Promise.all([
|
||||
getEndpoints(0, 100, {
|
||||
edgeDeviceFilter: 'trusted',
|
||||
}),
|
||||
GroupService.groups(),
|
||||
]);
|
||||
ctrl.groups = groups;
|
||||
ctrl.edgeDevices = endpointsResponse.value;
|
||||
} catch (err) {
|
||||
|
||||
@@ -68,9 +68,11 @@
|
||||
<tr>
|
||||
<td>Creation</td>
|
||||
<td>
|
||||
<span ng-if="ctrl.application.ApplicationOwner" style="margin-right: 5px"> <i class="fas fa-user"></i> {{ ctrl.application.ApplicationOwner }} </span>
|
||||
<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> <i class="fas fa-clock"></i> {{ ctrl.application.CreationDate | getisodate }}</span>
|
||||
<span ng-if="ctrl.application.ApplicationOwner">
|
||||
<span ng-if="ctrl.application.ApplicationOwner" data-cy="k8sAppDetail-creationMethod">
|
||||
<i class="fa fa-file-code space-left space-right" aria-hidden="true"></i> Deployed from {{ ctrl.state.appType }}</span
|
||||
>
|
||||
</td>
|
||||
|
||||
@@ -303,6 +303,7 @@
|
||||
</portainer-tooltip>
|
||||
<span
|
||||
class="label label-default interactive"
|
||||
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
|
||||
style="margin-left: 10px"
|
||||
ng-click="$ctrl.addRewriteAnnotation(ic)"
|
||||
data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.display-text {
|
||||
|
||||
@@ -21,13 +21,12 @@ export function useCopy(copyText: string, fadeDelay = 1000) {
|
||||
navigator.clipboard.writeText(copyText);
|
||||
} else {
|
||||
// https://stackoverflow.com/a/57192718
|
||||
const inputEl = document.createElement('input');
|
||||
const inputEl = document.createElement('textarea');
|
||||
inputEl.value = copyText;
|
||||
inputEl.type = 'text';
|
||||
document.body.appendChild(inputEl);
|
||||
inputEl.select();
|
||||
document.execCommand('copy');
|
||||
inputEl.type = 'hidden';
|
||||
inputEl.hidden = true;
|
||||
document.body.removeChild(inputEl);
|
||||
}
|
||||
setCopiedSuccessfully(true);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.code {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
padding: 16px 90px;
|
||||
word-break: break-word;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.root {
|
||||
|
||||
15
app/portainer/components/DetailsTable/DetailsRow.tsx
Normal file
15
app/portainer/components/DetailsTable/DetailsRow.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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',
|
||||
};
|
||||
24
app/portainer/components/DetailsTable/DetailsTable.test.tsx
Normal file
24
app/portainer/components/DetailsTable/DetailsTable.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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();
|
||||
});
|
||||
27
app/portainer/components/DetailsTable/DetailsTable.tsx
Normal file
27
app/portainer/components/DetailsTable/DetailsTable.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
13
app/portainer/components/DetailsTable/index.tsx
Normal file
13
app/portainer/components/DetailsTable/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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 };
|
||||
@@ -24,7 +24,6 @@
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,3 +8,8 @@
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.searchBar .textSpan {
|
||||
display: inline-block;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function FilterSearchBar({
|
||||
<span className={styles.iconSpan}>
|
||||
<i className="fa fa-search" aria-hidden="true" />
|
||||
</span>
|
||||
<span className={styles.iconSpan}>
|
||||
<span className={styles.textSpan}>
|
||||
<input
|
||||
type="text"
|
||||
className="searchInput"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.sort-by-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -8,3 +7,12 @@
|
||||
.sort-by-element {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sort-button {
|
||||
background-color: var(--bg-sortbutton-color);
|
||||
color: var(--text-ui-select-color);
|
||||
border: 1px solid var(--border-sortbutton);
|
||||
display: inline-block;
|
||||
padding: 8px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { Filter } from '@/portainer/home/types';
|
||||
|
||||
import styles from './SortbySelector.module.css';
|
||||
@@ -13,6 +12,7 @@ interface Props {
|
||||
placeHolder: string;
|
||||
sortByDescending: boolean;
|
||||
sortByButton: boolean;
|
||||
value?: Filter;
|
||||
}
|
||||
|
||||
export function SortbySelector({
|
||||
@@ -22,16 +22,17 @@ export function SortbySelector({
|
||||
placeHolder,
|
||||
sortByDescending,
|
||||
sortByButton,
|
||||
value,
|
||||
}: Props) {
|
||||
const upIcon = 'fa fa-sort-alpha-up';
|
||||
const downIcon = 'fa fa-sort-alpha-down';
|
||||
const [iconStyle, setIconStyle] = useState(upIcon);
|
||||
const [iconStyle, setIconStyle] = useState(downIcon);
|
||||
|
||||
useEffect(() => {
|
||||
if (sortByDescending) {
|
||||
setIconStyle(downIcon);
|
||||
} else {
|
||||
setIconStyle(upIcon);
|
||||
} else {
|
||||
setIconStyle(downIcon);
|
||||
}
|
||||
}, [sortByDescending]);
|
||||
|
||||
@@ -43,11 +44,13 @@ export function SortbySelector({
|
||||
options={filterOptions}
|
||||
onChange={(option) => onChange(option as Filter)}
|
||||
isClearable
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sortbyelement}>
|
||||
<Button
|
||||
size="medium"
|
||||
<div className={styles.sortByElement}>
|
||||
<button
|
||||
className={styles.sortButton}
|
||||
type="button"
|
||||
disabled={!sortByButton}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -55,7 +58,7 @@ export function SortbySelector({
|
||||
}}
|
||||
>
|
||||
<i className={iconStyle} />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,4 +45,19 @@
|
||||
:global :root[theme='dark'] :local .root :global .selector__option:active,
|
||||
:global :root[theme='dark'] :local .root :global .selector__option--is-focused {
|
||||
background-color: var(--blue-2);
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
.root :global .selector__option--is-selected {
|
||||
color: var(--grey-7);
|
||||
}
|
||||
|
||||
:global :root[theme='highcontrast'] :local .root :global .selector__single-value,
|
||||
:global :root[theme='dark'] :local .root :global .selector__single-value {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
:global :root[theme='highcontrast'] :local .root :global .selector__input-container,
|
||||
:global :root[theme='dark'] :local .root :global .selector__input-container {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
EnvironmentId,
|
||||
EnvironmentType,
|
||||
EnvironmentSettings,
|
||||
EnvironmentStatus,
|
||||
} from '../types';
|
||||
|
||||
import { arrayToJson, buildUrl } from './utils';
|
||||
@@ -19,14 +20,24 @@ export interface EnvironmentsQueryParams {
|
||||
tagIds?: TagId[];
|
||||
endpointIds?: EnvironmentId[];
|
||||
tagsPartialMatch?: boolean;
|
||||
groupId?: EnvironmentGroupId;
|
||||
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted';
|
||||
groupIds?: EnvironmentGroupId[];
|
||||
status?: EnvironmentStatus[];
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted' | 'none';
|
||||
}
|
||||
|
||||
export async function getEndpoints(
|
||||
start: number,
|
||||
limit: number,
|
||||
{ types, tagIds, endpointIds, ...query }: EnvironmentsQueryParams = {}
|
||||
{
|
||||
types,
|
||||
tagIds,
|
||||
endpointIds,
|
||||
status,
|
||||
groupIds,
|
||||
...query
|
||||
}: EnvironmentsQueryParams = {}
|
||||
) {
|
||||
if (tagIds && tagIds.length === 0) {
|
||||
return { totalCount: 0, value: <Environment[]>[] };
|
||||
@@ -48,6 +59,14 @@ export async function getEndpoints(
|
||||
params.endpointIds = arrayToJson(endpointIds);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
params.status = arrayToJson(status);
|
||||
}
|
||||
|
||||
if (groupIds) {
|
||||
params.groupIds = arrayToJson(groupIds);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get<Environment[]>(url, { params });
|
||||
const totalCount = response.headers['x-total-count'];
|
||||
@@ -94,7 +113,7 @@ export async function endpointsByGroup(
|
||||
search: string,
|
||||
groupId: EnvironmentGroupId
|
||||
) {
|
||||
return getEndpoints(start, limit, { search, groupId });
|
||||
return getEndpoints(start, limit, { search, groupIds: [groupId] });
|
||||
}
|
||||
|
||||
export async function disassociateEndpoint(id: EnvironmentId) {
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
.edit-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 7px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
|
||||
</span>
|
||||
</span>
|
||||
{groupName && (
|
||||
<span className="small">
|
||||
<span className="small space-right">
|
||||
<span>Group: </span>
|
||||
<span>{groupName}</span>
|
||||
</span>
|
||||
@@ -148,7 +148,7 @@ function useEnvironmentTagNames(tagIds?: TagId[]) {
|
||||
);
|
||||
});
|
||||
|
||||
if (tags) {
|
||||
if (tags && tags.length > 0) {
|
||||
return tags.join(', ');
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
.filter-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@@ -21,7 +20,7 @@
|
||||
|
||||
.filter-right {
|
||||
padding: 10px;
|
||||
width: 15%;
|
||||
width: 20%;
|
||||
right: 0;
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
@@ -33,3 +32,32 @@
|
||||
width: 5%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
display: inline-block;
|
||||
border: 0px;
|
||||
padding: 10px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.kubeconfig-button {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.filterSearchbar {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
EnvironmentType,
|
||||
EnvironmentStatus,
|
||||
} from '@/portainer/environments/types';
|
||||
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
||||
import {
|
||||
@@ -15,7 +16,10 @@ import {
|
||||
useSearchBarState,
|
||||
} from '@/portainer/components/datatables/components/FilterSearchBar';
|
||||
import { SortbySelector } from '@/portainer/components/datatables/components/SortbySelector';
|
||||
import { HomepageFilter } from '@/portainer/home/HomepageFilter';
|
||||
import {
|
||||
HomepageFilter,
|
||||
useHomePageFilter,
|
||||
} from '@/portainer/home/HomepageFilter';
|
||||
import {
|
||||
TableActions,
|
||||
TableContainer,
|
||||
@@ -38,42 +42,99 @@ interface Props {
|
||||
onRefresh(): void;
|
||||
}
|
||||
|
||||
const PlatformOptions = [
|
||||
{ value: EnvironmentType.Docker, label: 'Docker' },
|
||||
{ value: EnvironmentType.Azure, label: 'Azure' },
|
||||
{ value: EnvironmentType.KubernetesLocal, label: 'Kubernetes' },
|
||||
];
|
||||
|
||||
const status = [
|
||||
{ value: EnvironmentStatus.Up, label: 'Up' },
|
||||
{ value: EnvironmentStatus.Down, label: 'Down' },
|
||||
];
|
||||
|
||||
const SortByOptions = [
|
||||
{ value: 1, label: 'Name' },
|
||||
{ value: 2, label: 'Group' },
|
||||
{ value: 3, label: 'Status' },
|
||||
];
|
||||
|
||||
const storageKey = 'home_endpoints';
|
||||
const allEnvironmentType = [
|
||||
EnvironmentType.Docker,
|
||||
EnvironmentType.AgentOnDocker,
|
||||
EnvironmentType.Azure,
|
||||
EnvironmentType.EdgeAgentOnDocker,
|
||||
EnvironmentType.KubernetesLocal,
|
||||
EnvironmentType.AgentOnKubernetes,
|
||||
EnvironmentType.EdgeAgentOnKubernetes,
|
||||
];
|
||||
|
||||
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
const isAdmin = useIsAdmin();
|
||||
const storageKey = 'home_endpoints';
|
||||
const allEnvironmentType = [
|
||||
EnvironmentType.Docker,
|
||||
EnvironmentType.AgentOnDocker,
|
||||
EnvironmentType.Azure,
|
||||
EnvironmentType.EdgeAgentOnDocker,
|
||||
EnvironmentType.KubernetesLocal,
|
||||
EnvironmentType.AgentOnKubernetes,
|
||||
EnvironmentType.EdgeAgentOnKubernetes,
|
||||
];
|
||||
|
||||
const [platformType, setPlatformType] = useState(allEnvironmentType);
|
||||
const [platformType, setPlatformType] = useHomePageFilter(
|
||||
'platformType',
|
||||
allEnvironmentType
|
||||
);
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
|
||||
const [page, setPage] = useState(1);
|
||||
const debouncedTextFilter = useDebounce(searchBarValue);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
||||
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
||||
const [groupFilter, setGroupFilter] = useState<number[]>([]);
|
||||
const [sortByFilter, setSortByFilter] = useState<string>('');
|
||||
const [sortByDescending, setSortByDescending] = useState(false);
|
||||
const [sortByButton, setSortByButton] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useHomePageFilter<
|
||||
EnvironmentStatus[]
|
||||
>('status', []);
|
||||
const [tagFilter, setTagFilter] = useHomePageFilter<number[]>('tag', []);
|
||||
const [groupFilter, setGroupFilter] = useHomePageFilter<EnvironmentGroupId[]>(
|
||||
'group',
|
||||
[]
|
||||
);
|
||||
const [sortByFilter, setSortByFilter] = useSearchBarState('sortBy');
|
||||
const [sortByDescending, setSortByDescending] = useHomePageFilter(
|
||||
'sortOrder',
|
||||
false
|
||||
);
|
||||
const [sortByButton, setSortByButton] = useHomePageFilter(
|
||||
'sortByButton',
|
||||
false
|
||||
);
|
||||
|
||||
const [platformState, setPlatformState] = useState<Filter[]>([]);
|
||||
const [statusState, setStatusState] = useState<Filter[]>([]);
|
||||
const [tagState, setTagState] = useState<Filter[]>([]);
|
||||
const [groupState, setGroupState] = useState<Filter[]>([]);
|
||||
const [platformState, setPlatformState] = useHomePageFilter<Filter[]>(
|
||||
'type_state',
|
||||
[]
|
||||
);
|
||||
const [statusState, setStatusState] = useHomePageFilter<Filter[]>(
|
||||
'status_state',
|
||||
[]
|
||||
);
|
||||
const [tagState, setTagState] = useHomePageFilter<Filter[]>('tag_state', []);
|
||||
const [groupState, setGroupState] = useHomePageFilter<Filter[]>(
|
||||
'group_state',
|
||||
[]
|
||||
);
|
||||
const [sortByState, setSortByState] = useHomePageFilter<Filter | undefined>(
|
||||
'sortby_state',
|
||||
undefined
|
||||
);
|
||||
|
||||
const groupsQuery = useGroups();
|
||||
|
||||
const { isLoading, environments, totalCount, totalAvailable } =
|
||||
useEnvironmentList(
|
||||
{ page, pageLimit, types: platformType, search: debouncedTextFilter },
|
||||
{
|
||||
page,
|
||||
pageLimit,
|
||||
types: platformType,
|
||||
search: debouncedTextFilter,
|
||||
status: statusFilter,
|
||||
tagIds: tagFilter?.length ? tagFilter : undefined,
|
||||
groupIds: groupFilter,
|
||||
sort: sortByFilter,
|
||||
order: sortByDescending ? 'desc' : 'asc',
|
||||
edgeDeviceFilter: 'none',
|
||||
tagsPartialMatch: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
@@ -81,29 +142,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
setPage(1);
|
||||
}, [searchBarValue]);
|
||||
|
||||
interface Collection {
|
||||
Status: number[];
|
||||
TagIds: number[];
|
||||
GroupId: number[];
|
||||
}
|
||||
|
||||
const PlatformOptions = [
|
||||
{ value: EnvironmentType.Docker, label: 'Docker' },
|
||||
{ value: EnvironmentType.Azure, label: 'Azure' },
|
||||
{ value: EnvironmentType.KubernetesLocal, label: 'Kubernetes' },
|
||||
];
|
||||
|
||||
const status = [
|
||||
{ value: EnvironmentStatus.Up, label: 'Up' },
|
||||
{ value: EnvironmentStatus.Down, label: 'Down' },
|
||||
];
|
||||
|
||||
const SortByOptions = [
|
||||
{ value: 1, label: 'Name' },
|
||||
{ value: 2, label: 'Group' },
|
||||
{ value: 3, label: 'Status' },
|
||||
];
|
||||
|
||||
const groupOptions = [...(groupsQuery.data || [])];
|
||||
const uniqueGroup = [
|
||||
...new Map(groupOptions.map((item) => [item.Id, item])).values(),
|
||||
@@ -121,64 +159,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
label,
|
||||
}));
|
||||
|
||||
const collection = {
|
||||
Status: statusFilter,
|
||||
TagIds: tagFilter,
|
||||
GroupId: groupFilter,
|
||||
};
|
||||
|
||||
function multiPropsFilter(
|
||||
environments: Environment[],
|
||||
collection: Collection,
|
||||
sortByFilter: string,
|
||||
sortByDescending: boolean
|
||||
) {
|
||||
const filterKeys = Object.keys(collection);
|
||||
const filterResult = environments.filter((environment: Environment) =>
|
||||
filterKeys.every((key) => {
|
||||
if (!collection[key as keyof Collection].length) return true;
|
||||
if (Array.isArray(environment[key as keyof Collection])) {
|
||||
return (environment[key as keyof Collection] as number[]).some(
|
||||
(keyEle) => collection[key as keyof Collection].includes(keyEle)
|
||||
);
|
||||
}
|
||||
return collection[key as keyof Collection].includes(
|
||||
environment[key as keyof Collection] as number
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
switch (sortByFilter) {
|
||||
case 'Name':
|
||||
return sortByDescending
|
||||
? filterResult.sort((a, b) =>
|
||||
b.Name.toUpperCase() > a.Name.toUpperCase() ? 1 : -1
|
||||
)
|
||||
: filterResult.sort((a, b) =>
|
||||
a.Name.toUpperCase() > b.Name.toUpperCase() ? 1 : -1
|
||||
);
|
||||
case 'Group':
|
||||
return sortByDescending
|
||||
? filterResult.sort((a, b) => b.GroupId - a.GroupId)
|
||||
: filterResult.sort((a, b) => a.GroupId - b.GroupId);
|
||||
case 'Status':
|
||||
return sortByDescending
|
||||
? filterResult.sort((a, b) => b.Status - a.Status)
|
||||
: filterResult.sort((a, b) => a.Status - b.Status);
|
||||
case 'None':
|
||||
return filterResult;
|
||||
default:
|
||||
return filterResult;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEnvironments: Environment[] = multiPropsFilter(
|
||||
environments,
|
||||
collection,
|
||||
sortByFilter,
|
||||
sortByDescending
|
||||
);
|
||||
|
||||
function platformOnChange(filterOptions: Filter[]) {
|
||||
setPlatformState(filterOptions);
|
||||
const dockerBaseType = EnvironmentType.Docker;
|
||||
@@ -192,24 +172,21 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
EnvironmentType.EdgeAgentOnKubernetes,
|
||||
];
|
||||
|
||||
let finalFilterEnvironment: number[] = [];
|
||||
|
||||
if (filterOptions.length === 0) {
|
||||
setPlatformType(allEnvironmentType);
|
||||
} else {
|
||||
const filteredEnvironment = [
|
||||
...new Set(
|
||||
filterOptions.map(
|
||||
(filterOptions: { value: number }) => filterOptions.value
|
||||
)
|
||||
),
|
||||
];
|
||||
if (filteredEnvironment.includes(dockerBaseType)) {
|
||||
finalFilterEnvironment = [...filteredEnvironment, ...dockerRelateType];
|
||||
}
|
||||
if (filteredEnvironment.includes(kubernetesBaseType)) {
|
||||
let finalFilterEnvironment = filterOptions.map(
|
||||
(filterOption) => filterOption.value
|
||||
);
|
||||
if (finalFilterEnvironment.includes(dockerBaseType)) {
|
||||
finalFilterEnvironment = [
|
||||
...filteredEnvironment,
|
||||
...finalFilterEnvironment,
|
||||
...dockerRelateType,
|
||||
];
|
||||
}
|
||||
if (finalFilterEnvironment.includes(kubernetesBaseType)) {
|
||||
finalFilterEnvironment = [
|
||||
...finalFilterEnvironment,
|
||||
...kubernetesRelateType,
|
||||
];
|
||||
}
|
||||
@@ -266,7 +243,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
setSearchBarValue('');
|
||||
setPlatformState([]);
|
||||
setPlatformType(allEnvironmentType);
|
||||
setStatusState([]);
|
||||
@@ -281,9 +257,11 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
if (filterOptions !== null) {
|
||||
setSortByFilter(filterOptions.label);
|
||||
setSortByButton(true);
|
||||
setSortByState(filterOptions);
|
||||
} else {
|
||||
setSortByFilter('None');
|
||||
setSortByButton(false);
|
||||
setSortByFilter('');
|
||||
setSortByButton(true);
|
||||
setSortByState(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,19 +282,34 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
<i className="fa fa-exclamation-circle blue-icon space-right" />
|
||||
Click on an environment to manage
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
data-cy="home-refreshEndpointsButton"
|
||||
className={clsx(styles.refreshEnvironmentsButton)}
|
||||
>
|
||||
<i className="fa fa-sync space-right" aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<KubeconfigButton environments={environments} />
|
||||
<div className={styles.actionButton}>
|
||||
<div className={styles.refreshButton}>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
data-cy="home-refreshEndpointsButton"
|
||||
className={clsx(styles.refreshEnvironmentsButton)}
|
||||
>
|
||||
<i
|
||||
className="fa fa-sync space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.kubeconfigButton}>
|
||||
<KubeconfigButton environments={environments} />
|
||||
</div>
|
||||
<div className={styles.filterSearchbar}>
|
||||
<FilterSearchBar
|
||||
value={searchBarValue}
|
||||
onChange={setSearchBarValue}
|
||||
placeholder="Search by name, group, tag, status, URL..."
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TableActions>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterLeft}>
|
||||
@@ -351,19 +344,13 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
value={groupState}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterLeft}>
|
||||
<FilterSearchBar
|
||||
value={searchBarValue}
|
||||
onChange={setSearchBarValue}
|
||||
placeholder="Search...."
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterButton}>
|
||||
<Button size="medium" onClick={clearFilter}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={clearFilter}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
<div className={styles.filterRight}>
|
||||
<SortbySelector
|
||||
filterOptions={SortByOptions}
|
||||
@@ -372,6 +359,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
placeHolder="Sort By"
|
||||
sortByDescending={sortByDescending}
|
||||
sortByButton={sortByButton}
|
||||
value={sortByState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,7 +367,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
{renderItems(
|
||||
isLoading,
|
||||
totalCount,
|
||||
filteredEnvironments.map((env) => (
|
||||
environments.map((env) => (
|
||||
<EnvironmentItem
|
||||
key={env.Id}
|
||||
environment={env}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { components, OptionProps } from 'react-select';
|
||||
|
||||
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||
import { Filter } from '@/portainer/home/types';
|
||||
|
||||
@@ -42,3 +43,33 @@ export function HomepageFilter({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHomePageFilter<T>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): [T, (value: T) => void] {
|
||||
const filterKey = keyBuilder(key);
|
||||
const [storageValue, setStorageValue] = useLocalStorage(
|
||||
filterKey,
|
||||
JSON.stringify(defaultValue),
|
||||
sessionStorage
|
||||
);
|
||||
const value = jsonParse(storageValue, defaultValue);
|
||||
return [value, setValue];
|
||||
|
||||
function setValue(value?: T) {
|
||||
setStorageValue(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
function keyBuilder(key: string) {
|
||||
return `datatable_home_filter_type_${key}`;
|
||||
}
|
||||
|
||||
function jsonParse<T>(value: string, defaultValue: T): T {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,18 @@ export function parseAxiosError(
|
||||
if ('isAxiosError' in err) {
|
||||
const { error, details } = parseError(err as AxiosError);
|
||||
resultErr = error;
|
||||
resultMsg = msg ? `${msg}: ${details}` : details;
|
||||
if (msg && details) {
|
||||
resultMsg = `${msg}: ${details}`;
|
||||
} else {
|
||||
resultMsg = msg || 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 };
|
||||
|
||||
@@ -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 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>
|
||||
<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>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<button
|
||||
|
||||
@@ -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 m-b-10" ng-if="$index > 0">
|
||||
<div class="form-group mb-3" 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 m-t-10">
|
||||
<div class="form-group mt-3">
|
||||
<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 m-t-10">
|
||||
<div class="col-sm-12 mt-3">
|
||||
<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="m-l-30"> <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="ml-5"> <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 m-l-20" ng-class="{ 'business limited': $ctrl.isLimitedFeatureSelfContained }">
|
||||
<label class="switch ml-7" 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="m-l-20"
|
||||
class="ml-7"
|
||||
isteven-multi-select
|
||||
ng-if="$ctrl.enableAssignAdminGroup"
|
||||
input-model="$ctrl.groups"
|
||||
|
||||
@@ -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 m-r-2" aria-hidden="true"></i>
|
||||
<i class="fa fa-info-circle blue-icon mr-1" 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 dispay-flex flex-wrap">
|
||||
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left flex flex-wrap">
|
||||
LDAP Server
|
||||
<button
|
||||
type="button"
|
||||
@@ -32,7 +32,7 @@
|
||||
</button>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<div class="m-b-10 dispay-flex" ng-repeat="url in $ctrl.settings.URLs track by $index">
|
||||
<div class="mb-3 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,35 +97,39 @@
|
||||
connectivity-check="$ctrl.connectivityCheck"
|
||||
></ldap-connectivity-check>
|
||||
|
||||
<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>
|
||||
<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-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-group-search
|
||||
class="block"
|
||||
settings="$ctrl.settings.GroupSearchSettings"
|
||||
on-search-click="($ctrl.onSearchGroupsClick)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-custom-group-search>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<save-auth-settings-button
|
||||
on-save-settings="($ctrl.onSaveSettings)"
|
||||
|
||||
@@ -19,15 +19,24 @@ interface FormValues {
|
||||
TrustOnFirstConnect: boolean;
|
||||
}
|
||||
const validation = yup.object({
|
||||
TrustOnFirstConnect: yup.boolean().required('This field is required.'),
|
||||
TrustOnFirstConnect: yup.boolean(),
|
||||
EdgePortainerUrl: yup
|
||||
.string()
|
||||
.test(
|
||||
'not-local',
|
||||
'Cannot use localhost as environment URL',
|
||||
(value) => !value?.includes('localhost')
|
||||
'url',
|
||||
'URL should be a valid URI and cannot include localhost',
|
||||
(value) => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return !!url.hostname && url.hostname !== 'localhost';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
)
|
||||
.url('URL should be a valid URI')
|
||||
.required('URL is required'),
|
||||
});
|
||||
|
||||
@@ -63,10 +72,10 @@ export function AutoEnvCreationSettingsForm({ settings }: Props) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url && validation.isValidSync({ url: defaultUrl })) {
|
||||
handleSubmit({ EdgePortainerUrl: defaultUrl });
|
||||
if (!url && validation.isValidSync({ EdgePortainerUrl: defaultUrl })) {
|
||||
updateSettings({ EdgePortainerUrl: defaultUrl });
|
||||
}
|
||||
}, [handleSubmit, url]);
|
||||
}, [updateSettings, url]);
|
||||
|
||||
return (
|
||||
<Formik<FormValues>
|
||||
|
||||
@@ -2,10 +2,11 @@ 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';
|
||||
|
||||
@@ -23,39 +24,18 @@ 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={initialValues}
|
||||
initialValues={settings}
|
||||
enableReinitialize
|
||||
validationSchema={() => validationSchema()}
|
||||
onSubmit={onSubmit}
|
||||
@@ -76,26 +56,7 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
|
||||
noValidate
|
||||
>
|
||||
<FormControl
|
||||
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"
|
||||
inputId="edge_enable"
|
||||
label="Enable Edge Compute features"
|
||||
size="medium"
|
||||
errors={errors.EnableEdgeComputeFeatures}
|
||||
@@ -134,6 +95,18 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user