Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3eda4bce | ||
|
|
889c36f64a | ||
|
|
c8fb3adda3 | ||
|
|
f15be1d92a | ||
|
|
d9ae249ffe | ||
|
|
04de06c07f | ||
|
|
59d53940fe | ||
|
|
db16888379 | ||
|
|
8880876bcd | ||
|
|
bfe5a49263 | ||
|
|
6e11c10bab | ||
|
|
cb9ab3b375 | ||
|
|
b13dac0f6d | ||
|
|
0144a98b3b | ||
|
|
64a08c59e9 | ||
|
|
1090c82beb | ||
|
|
6094dc115b | ||
|
|
30513695b5 | ||
|
|
dd2be9fb1e | ||
|
|
e265b8b67c | ||
|
|
cc1ce9412a | ||
|
|
8eb8df2b30 | ||
|
|
c0bd2dfdaf | ||
|
|
bf65a38d5a | ||
|
|
0ea21f2317 | ||
|
|
b5f839a920 | ||
|
|
29025e7dd4 | ||
|
|
692981b615 | ||
|
|
d6545b6af5 | ||
|
|
6bbf62fe64 | ||
|
|
6b3ddf11d4 | ||
|
|
77c9124e8a | ||
|
|
2c3dcdd14e | ||
|
|
ec913b45d6 | ||
|
|
51c672af21 | ||
|
|
ff178641be | ||
|
|
a43454076b | ||
|
|
a7eaa0f3fa | ||
|
|
8ad11fc88f | ||
|
|
43a95874f4 | ||
|
|
b4f4c3212a | ||
|
|
d44f57ed6f | ||
|
|
eba08cdca0 | ||
|
|
de3a3f88a0 | ||
|
|
f6b2c879bc | ||
|
|
f5fbcd4d9d | ||
|
|
f8b68a809f | ||
|
|
6258c02353 | ||
|
|
0fd20277c1 | ||
|
|
988064a542 | ||
|
|
380b23a9f5 | ||
|
|
158b43194c | ||
|
|
1bbe98379a | ||
|
|
8f9b265f5a | ||
|
|
1cdd3fdfe2 | ||
|
|
4e95139909 | ||
|
|
704d75596d | ||
|
|
a8938779bf | ||
|
|
bb6f4e026a | ||
|
|
b64166ff25 | ||
|
|
bac1c28fa9 | ||
|
|
a17da6d2cd | ||
|
|
24c2baf6cc | ||
|
|
22b4d029fd | ||
|
|
b126472ec7 | ||
|
|
a46fa3b2c4 | ||
|
|
a374157d6f | ||
|
|
861ed662e2 | ||
|
|
99b89a8ec5 | ||
|
|
95750c2339 | ||
|
|
165d6165dc | ||
|
|
fe6ed55cab | ||
|
|
edea9e3481 | ||
|
|
c08b5af85a | ||
|
|
ed861044a7 | ||
|
|
a83321ebe6 | ||
|
|
513cd9c9b3 | ||
|
|
dc94bf141e | ||
|
|
24471a9ae1 | ||
|
|
aca6d33548 | ||
|
|
ca77b85c65 | ||
|
|
1fd4291630 | ||
|
|
08dd7f6d2a | ||
|
|
ce4b0e759c | ||
|
|
538e7a823b | ||
|
|
956e8d3c59 | ||
|
|
1c5458f0d4 | ||
|
|
f6085ffad7 | ||
|
|
490bda2eaf | ||
|
|
d601d8eb7b | ||
|
|
b0564b9238 | ||
|
|
8922585a70 | ||
|
|
d7cf2284dc |
@@ -10,6 +10,7 @@ globals:
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- 'plugin:import/typescript'
|
||||
- prettier
|
||||
|
||||
plugins:
|
||||
@@ -29,6 +30,7 @@ rules:
|
||||
no-empty: warn
|
||||
no-empty-function: warn
|
||||
no-useless-escape: 'off'
|
||||
import/named: error
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
@@ -43,6 +45,12 @@ rules:
|
||||
pathGroupsExcludedImportTypes: ['internal'],
|
||||
},
|
||||
]
|
||||
no-restricted-imports:
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
|
||||
settings:
|
||||
'import/resolver':
|
||||
@@ -51,6 +59,8 @@ settings:
|
||||
- ['@@', './app/react/components']
|
||||
- ['@', './app']
|
||||
extensions: ['.js', '.ts', '.tsx']
|
||||
typescript: true
|
||||
node: true
|
||||
|
||||
overrides:
|
||||
- files:
|
||||
@@ -75,6 +85,7 @@ overrides:
|
||||
settings:
|
||||
react:
|
||||
version: 'detect'
|
||||
|
||||
rules:
|
||||
import/order:
|
||||
[
|
||||
@@ -108,6 +119,12 @@ overrides:
|
||||
'no-await-in-loop': 'off'
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
|
||||
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
|
||||
'@typescript-eslint/no-restricted-imports':
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
overrides: # allow props spreading for hoc files
|
||||
- files:
|
||||
- app/**/with*.ts{,x}
|
||||
@@ -121,7 +138,11 @@ overrides:
|
||||
'vitest/env': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
'no-alert': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
|
||||
125
.github/workflows/ci.yaml
vendored
125
.github/workflows/ci.yaml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
- '!release/*'
|
||||
- 'release/*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'develop'
|
||||
@@ -20,8 +20,8 @@ on:
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
DOCKER_HUB_REPO: portainerci/portainer
|
||||
NODE_ENV: testing
|
||||
DOCKER_HUB_REPO: portainerci/portainer-ce
|
||||
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
@@ -30,81 +30,59 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- { platform: linux, arch: amd64 }
|
||||
- { platform: linux, arch: arm64 }
|
||||
- { platform: linux, arch: amd64, version: "" }
|
||||
- { platform: linux, arch: arm64, version: "" }
|
||||
- { platform: linux, arch: arm, version: "" }
|
||||
- { platform: linux, arch: ppc64le, version: "" }
|
||||
- { platform: linux, arch: s390x, version: "" }
|
||||
- { platform: windows, arch: amd64, version: 1809 }
|
||||
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||
runs-on: arc-runner-set
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
steps:
|
||||
- name: '[preparation] checkout the current branch'
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
- name: '[preparation] set up golang'
|
||||
uses: actions/setup-go@v4.0.1
|
||||
uses: actions/setup-go@v5.0.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
- name: '[preparation] cache paths'
|
||||
id: cache-dir-path
|
||||
run: |
|
||||
echo "yarn-cache-dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-build-dir=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-mod-dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
|
||||
- name: '[preparation] cache go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ steps.cache-dir-path.outputs.go-build-dir }}
|
||||
${{ steps.cache-dir-path.outputs.go-mod-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-
|
||||
enableCrossOsArchive: true
|
||||
- name: '[preparation] set up node.js'
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4.0.1
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: ''
|
||||
- name: '[preparation] cache yarn'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
${{ steps.cache-dir-path.outputs.yarn-cache-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-
|
||||
enableCrossOsArchive: true
|
||||
cache: 'yarn'
|
||||
- name: '[preparation] set up qemu'
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set the container image tag'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
# use the release branch name as the tag for release branches
|
||||
# for instance, release/2.19 becomes 2.19
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
# use pr${{ github.event.number }} as the tag for pull requests
|
||||
# for instance, pr123
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
if [ "${{ matrix.config.platform }}" == "windows" ]; then
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}"
|
||||
else
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}-${{ matrix.config.arch }}"
|
||||
fi
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}" >> $GITHUB_ENV
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}" >> $GITHUB_ENV
|
||||
- name: '[execution] build linux & windows portainer binaries'
|
||||
run: |
|
||||
export YARN_VERSION=$(yarn --version)
|
||||
@@ -112,6 +90,12 @@ jobs:
|
||||
export BUILDNUMBER=${GITHUB_RUN_NUMBER}
|
||||
GIT_COMMIT_HASH_LONG=${{ github.sha }}
|
||||
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
|
||||
|
||||
NODE_ENV="testing"
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
NODE_ENV="production"
|
||||
fi
|
||||
|
||||
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
@@ -123,35 +107,70 @@ jobs:
|
||||
else
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
fi
|
||||
fi
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
build_manifests:
|
||||
runs-on: arc-runner-set
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
needs: [build_images]
|
||||
steps:
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker version && docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[execution] build and push manifests'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
# use the release branch name as the tag for release branches
|
||||
# for instance, release/2.19 becomes 2.19
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
# use pr${{ github.event.number }} as the tag for pull requests
|
||||
# for instance, pr123
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
|
||||
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
fi
|
||||
|
||||
8
.github/workflows/test.yaml
vendored
8
.github/workflows/test.yaml
vendored
@@ -6,12 +6,20 @@ env:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
test-client:
|
||||
|
||||
@@ -23,3 +23,29 @@ func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
|
||||
|
||||
return migrator.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
|
||||
// In PortainerCE the resource overcommit option should always be true across all endpoints
|
||||
func (migrator *Migrator) updateResourceOverCommitToDB110() error {
|
||||
log.Info().Msg("updating resource overcommit setting to true")
|
||||
|
||||
endpoints, err := migrator.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
|
||||
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = true
|
||||
|
||||
err = migrator.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ func (m *Migrator) initMigrations() {
|
||||
)
|
||||
m.addMigrations("2.20",
|
||||
m.updateAppTemplatesVersionForDB110,
|
||||
m.updateResourceOverCommitToDB110,
|
||||
)
|
||||
|
||||
// Add new migrations below...
|
||||
|
||||
@@ -939,6 +939,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler)
|
||||
handler = gorillacsrf.Protect(
|
||||
[]byte(token),
|
||||
gorillacsrf.Path("/"),
|
||||
gorillacsrf.Secure(false),
|
||||
)(handler)
|
||||
|
||||
return withSkipCSRF(handler), nil
|
||||
}
|
||||
|
||||
@@ -135,6 +135,11 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
}
|
||||
|
||||
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
|
||||
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
|
||||
delete(stack.Status, environmentId)
|
||||
return
|
||||
}
|
||||
|
||||
environmentStatus, ok := stack.Status[environmentId]
|
||||
if !ok {
|
||||
environmentStatus = portainer.EdgeStackStatus{
|
||||
|
||||
@@ -38,19 +38,20 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
kubeClusterAccessService: kubeClusterAccessService,
|
||||
}
|
||||
|
||||
h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
|
||||
h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"),
|
||||
bouncer.AuthenticatedAccess)
|
||||
|
||||
// `helm list -o json`
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmList))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmList)).Methods(http.MethodGet)
|
||||
|
||||
// `helm delete RELEASE_NAME`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmDelete))).Methods(http.MethodDelete)
|
||||
httperror.LoggerHandler(h.helmDelete)).Methods(http.MethodDelete)
|
||||
|
||||
// `helm install [NAME] [CHART] flags`
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost)
|
||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||
|
||||
// Deprecated
|
||||
h.Handle("/{id}/kubernetes/helm/repositories",
|
||||
@@ -69,12 +70,14 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
|
||||
h.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
h.Handle("/templates/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmRepoSearch))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmRepoSearch)).Methods(http.MethodGet)
|
||||
|
||||
// helm show [COMMAND] [CHART] [REPO] flags
|
||||
h.Handle("/templates/helm/{command:chart|values|readme}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmShow))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmShow)).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type stackListOperationFilters struct {
|
||||
// @description List all stacks based on the current user authorizations.
|
||||
// @description Will return all stacks if using an administrator account otherwise it
|
||||
// @description will only return the list of stacks the user have access to.
|
||||
// @description Limited stacks will not be returned by this endpoint.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags stacks
|
||||
// @security ApiKeyAuth
|
||||
|
||||
@@ -27,6 +27,8 @@ type stackGitRedployPayload struct {
|
||||
Prune bool
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
|
||||
StackName string
|
||||
}
|
||||
|
||||
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
@@ -44,7 +46,7 @@ func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
// @produce json
|
||||
// @param id path int true "Stack identifier"
|
||||
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack."
|
||||
// @param body body stackGitRedployPayload true "Git configs for pull and redeploy a stack"
|
||||
// @param body body stackGitRedployPayload true "Git configs for pull and redeploy of a stack. **StackName** may only be populated for Kuberenetes stacks, and if specified with a blank string, it will be set to blank"
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
@@ -136,6 +138,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
if stack.Type == portainer.KubernetesStack {
|
||||
stack.Name = payload.StackName
|
||||
}
|
||||
|
||||
repositoryUsername := ""
|
||||
repositoryPassword := ""
|
||||
if payload.RepositoryAuthentication {
|
||||
|
||||
@@ -60,8 +60,13 @@ type accessTokenResponse struct {
|
||||
// @router /users/{id}/tokens [post]
|
||||
func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
// specifically require Cookie auth for this endpoint since API-Key based auth is not supported
|
||||
if jwt, _ := handler.bouncer.CookieAuthLookup(r); jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Cookie Authentication required"))
|
||||
jwt, _ := handler.bouncer.CookieAuthLookup(r)
|
||||
if jwt == nil {
|
||||
jwt, _ = handler.bouncer.JWTAuthLookup(r)
|
||||
}
|
||||
|
||||
if jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Authentication required"))
|
||||
}
|
||||
|
||||
var payload userAccessTokenCreatePayload
|
||||
|
||||
@@ -107,7 +107,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
is.Equal(`{"message":"Auth not supported","details":"Cookie Authentication required"}`, string(body))
|
||||
is.Equal(`{"message":"Auth not supported","details":"Authentication required"}`, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ type webhookListOperationFilters struct {
|
||||
// @tags webhooks
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param filters query webhookListOperationFilters false "Filters"
|
||||
// @param filters query string false "Filters (json-string)" example({"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"})
|
||||
// @success 200 {array} portainer.Webhook
|
||||
// @failure 400
|
||||
// @failure 500
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -27,6 +29,7 @@ type (
|
||||
AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error
|
||||
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
|
||||
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
}
|
||||
|
||||
// RequestBouncer represents an entity that manages API request accesses
|
||||
@@ -280,7 +283,7 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
||||
for _, lookup := range tokenLookups {
|
||||
resultToken, err := lookup(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid API key", httperrors.ErrUnauthorized)
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", httperrors.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -316,7 +319,7 @@ func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.Tok
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
@@ -332,7 +335,7 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenD
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
@@ -366,7 +369,8 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
|
||||
Role: user.Role,
|
||||
}
|
||||
if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
log.Debug().Err(err).Msg("Failed to generate token")
|
||||
return nil, fmt.Errorf("failed to generate token")
|
||||
}
|
||||
|
||||
if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds]
|
||||
|
||||
@@ -54,6 +54,10 @@ func (testRequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenDat
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// AddTestSecurityCookie adds a security cookie to the request
|
||||
func AddTestSecurityCookie(r *http.Request, jwt string) {
|
||||
r.AddCookie(&http.Cookie{
|
||||
|
||||
@@ -3,10 +3,11 @@ package portainer
|
||||
func KubernetesDefault() KubernetesData {
|
||||
return KubernetesData{
|
||||
Configuration: KubernetesConfiguration{
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
StorageClasses: []KubernetesStorageClassConfig{},
|
||||
IngressClasses: []KubernetesIngressClassConfig{},
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
EnableResourceOverCommit: true,
|
||||
StorageClasses: []KubernetesStorageClassConfig{},
|
||||
IngressClasses: []KubernetesIngressClassConfig{},
|
||||
},
|
||||
Snapshots: []KubernetesSnapshot{},
|
||||
}
|
||||
|
||||
@@ -241,7 +241,10 @@ func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) err
|
||||
// UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error {
|
||||
ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace)
|
||||
var ingress netv1.Ingress
|
||||
ingress, err := ingressClient.Get(context.Background(), info.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ingress.Name = info.Name
|
||||
ingress.Namespace = info.Namespace
|
||||
@@ -278,6 +281,7 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
|
||||
})
|
||||
}
|
||||
|
||||
ingress.Spec.Rules = make([]netv1.IngressRule, 0)
|
||||
for rule, paths := range rules {
|
||||
ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{
|
||||
Host: rule,
|
||||
@@ -299,6 +303,6 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
|
||||
}
|
||||
}
|
||||
|
||||
_, err := ingressClient.Update(context.Background(), &ingress, metav1.UpdateOptions{})
|
||||
_, err = ingressClient.Update(context.Background(), ingress, metav1.UpdateOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -73,31 +73,30 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
|
||||
ns.Annotations = info.Annotations
|
||||
ns.Labels = portainerLabels
|
||||
|
||||
resourceQuota := &v1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: v1.ResourceQuotaSpec{
|
||||
Hard: v1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("Namespace", info.Name).
|
||||
Interface("ResourceQuota", resourceQuota).
|
||||
Msg("Failed to create the namespace due to a resource quota issue.")
|
||||
Msg("Failed to create the namespace")
|
||||
return err
|
||||
}
|
||||
|
||||
if info.ResourceQuota != nil {
|
||||
if info.ResourceQuota != nil && info.ResourceQuota.Enabled {
|
||||
log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
|
||||
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
|
||||
|
||||
resourceQuota := &v1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: v1.ResourceQuotaSpec{
|
||||
Hard: v1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
if info.ResourceQuota.Enabled {
|
||||
memory := resource.MustParse(info.ResourceQuota.Memory)
|
||||
cpu := resource.MustParse(info.ResourceQuota.CPU)
|
||||
|
||||
@@ -82,6 +82,7 @@ function config($analyticsProvider, $windowProvider) {
|
||||
push('setReferrerUrl', '');
|
||||
push('setCustomUrl', basePath + path);
|
||||
push('trackPageView');
|
||||
push('enableLinkTracking');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
10
app/app.js
10
app/app.js
@@ -17,6 +17,16 @@ export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions
|
||||
HttpRequestHelper.resetAgentHeaders();
|
||||
});
|
||||
|
||||
// EE-6751: screens not loading when switching quickly between side menu options
|
||||
// Known bug of @uirouter/angularjs
|
||||
// Fix found at https://github.com/angular-ui/ui-router/issues/3652#issuecomment-574499009
|
||||
// This hook is cleaning the internal viewConfigs list, removing leftover data unrelated to the current transition
|
||||
$transitions.onStart({}, (transition) => {
|
||||
const toList = transition.treeChanges().to.map((t) => t.state.name);
|
||||
const toConfigs = transition.router.viewService._viewConfigs.filter((vc) => toList.includes(vc.viewDecl.$context.name));
|
||||
transition.router.viewService._viewConfigs = toConfigs;
|
||||
});
|
||||
|
||||
$(document).ajaxSend((event, jqXhr, jqOpts) => {
|
||||
const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH';
|
||||
const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type'];
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
html {
|
||||
font-size: 16px;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html[theme='dark'],
|
||||
|
||||
@@ -104,6 +104,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controllerAs: 'ctrl',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/configs/add',
|
||||
},
|
||||
};
|
||||
|
||||
const customTemplates = {
|
||||
@@ -122,7 +125,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
|
||||
const customTemplatesNew = {
|
||||
name: 'docker.templates.custom.new',
|
||||
url: '/new?appTemplateId&type',
|
||||
url: '/new?fileContent&appTemplateId&type',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
@@ -165,7 +168,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/host',
|
||||
docs: '/user/docker/host/details',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -227,6 +230,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'BuildImageController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/images/build',
|
||||
},
|
||||
};
|
||||
|
||||
var imageImport = {
|
||||
@@ -238,6 +244,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'ImportImageController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/images/import',
|
||||
},
|
||||
};
|
||||
|
||||
var networks = {
|
||||
@@ -273,6 +282,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateNetworkController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/networks/add',
|
||||
},
|
||||
};
|
||||
|
||||
var nodes = {
|
||||
@@ -280,7 +292,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
url: '/nodes',
|
||||
abstract: true,
|
||||
data: {
|
||||
docs: '/user/docker/swarm',
|
||||
docs: '/user/docker/swarm/details',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -338,6 +350,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateSecretController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/secrets/add',
|
||||
},
|
||||
};
|
||||
|
||||
var services = {
|
||||
@@ -374,6 +389,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateServiceController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/stacks/add',
|
||||
},
|
||||
};
|
||||
|
||||
var serviceLogs = {
|
||||
@@ -444,7 +462,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/swarm',
|
||||
docs: '/user/docker/swarm/details',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -500,7 +518,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/templates',
|
||||
docs: '/user/docker/templates/application',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -549,6 +567,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateVolumeController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/volumes/add',
|
||||
},
|
||||
};
|
||||
|
||||
const dockerFeaturesConfiguration = {
|
||||
|
||||
@@ -16,7 +16,7 @@ function ImageHelperFactory() {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('@/react/docker/images/queries/useImages').ImagesListResponse[]} images
|
||||
* @param {Array<{tags: Array<string>; id: string;}>} images
|
||||
* @returns {{names: string[]}}}
|
||||
*/
|
||||
function getImagesNamesForDownload(images) {
|
||||
|
||||
@@ -79,7 +79,7 @@ const ngModule = angular
|
||||
)
|
||||
.component(
|
||||
'dockerConfigsDatatable',
|
||||
r2a(withUIRouter(ConfigsDatatable), [
|
||||
r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
|
||||
'dataset',
|
||||
'onRemoveClick',
|
||||
'onRefresh',
|
||||
@@ -121,7 +121,11 @@ const ngModule = angular
|
||||
.component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset']))
|
||||
.component(
|
||||
'dockerSecretsDatatable',
|
||||
r2a(withUIRouter(SecretsDatatable), ['dataset', 'onRefresh', 'onRemove'])
|
||||
r2a(withUIRouter(withCurrentUser(SecretsDatatable)), [
|
||||
'dataset',
|
||||
'onRefresh',
|
||||
'onRemove',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'dockerStacksDatatable',
|
||||
|
||||
@@ -85,6 +85,9 @@ function config($stateRegistryProvider: StateRegistry) {
|
||||
component: 'createContainerView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/containers/add',
|
||||
},
|
||||
});
|
||||
|
||||
$stateRegistryProvider.register({
|
||||
|
||||
@@ -171,6 +171,11 @@ angular.module('portainer.docker').factory('ImageService', [
|
||||
return Image.tag({ id: id, repo: image }).$promise;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<{tags: Array<string>; id: string;}>} images
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
service.downloadImages = function (images) {
|
||||
var names = ImageHelper.getImagesNamesForDownload(images);
|
||||
return Image.download(names).$promise;
|
||||
|
||||
@@ -69,10 +69,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="state !== states.disconnected">
|
||||
<label class="control-label text-left"
|
||||
>Exec into container as <code>{{ ::formValues.user || 'default user' }}</code> using command
|
||||
<code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
|
||||
<terminal-tooltip> </terminal-tooltip>
|
||||
<label
|
||||
>Exec into container as <code class="!text-sm align-baseline">{{ ::formValues.user || 'default user' }}</code> using command
|
||||
<code class="!text-sm align-baseline">{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
|
||||
<terminal-tooltip class="align-sub"> </terminal-tooltip>
|
||||
</label>
|
||||
<button type="button" class="btn btn-primary" ng-click="disconnect()">
|
||||
<span ng-show="state === states.connected">Disconnect</span>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
||||
Portainer is connected to a node that is part of a Swarm cluster. Some resources located on other nodes in the cluster might not be available for management, have a look at
|
||||
<a href="https://docs.portainer.io/admin/environments/add/swarm/agent" target="_blank">our agent setup</a> for more details.
|
||||
<help-link doc-link="'/admin/environments/add/swarm/agent'" target="'_blank'" children="'our agent setup'"></help-link> for more details.
|
||||
</p>
|
||||
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'WORKER'">
|
||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
</uib-tab>
|
||||
<uib-tab index="1" disable="!buildLogs">
|
||||
<uib-tab-heading class="vertical-center"> <pr-icon icon="'file-text'" class="leading-none"></pr-icon> Output </uib-tab-heading>
|
||||
<pre class="log_viewer">
|
||||
<pre class="log_viewer" data-cy="logViewer">
|
||||
<div ng-repeat="line in buildLogs track by $index" class="line"><p class="inner_line" ng-click="active=!active" ng-class="{'line_selected': active}">{{ line }}</p></div>
|
||||
<div ng-if="!buildLogs.length" class="line"><p class="inner_line">No build output available.</p></div>
|
||||
</pre>
|
||||
|
||||
@@ -162,7 +162,7 @@ angular.module('portainer.docker').controller('ImageController', [
|
||||
function exportImage(image) {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName);
|
||||
$scope.state.exportInProgress = true;
|
||||
ImageService.downloadImages([image])
|
||||
ImageService.downloadImages([{ tags: image.RepoTags, id: image.Id }])
|
||||
.then(function success(data) {
|
||||
var downloadData = new Blob([data.file], { type: 'application/x-tar' });
|
||||
FileSaver.saveAs(downloadData, 'images.tar');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { AccessHeaders } from '@/portainer/authorization-guard';
|
||||
import edgeStackModule from './views/edge-stacks';
|
||||
import { reactModule } from './react';
|
||||
|
||||
@@ -12,6 +13,9 @@ angular
|
||||
url: '/edge',
|
||||
parent: 'root',
|
||||
abstract: true,
|
||||
data: {
|
||||
access: AccessHeaders.EdgeAdmin,
|
||||
},
|
||||
};
|
||||
|
||||
const groups = {
|
||||
@@ -62,12 +66,15 @@ angular
|
||||
|
||||
const stacksNew = {
|
||||
name: 'edge.stacks.new',
|
||||
url: '/new?templateId',
|
||||
url: '/new?templateId&templateType',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createEdgeStackView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/edge/stacks/add',
|
||||
},
|
||||
};
|
||||
|
||||
const stacksEdit = {
|
||||
@@ -137,7 +144,7 @@ angular
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/edge/devices',
|
||||
docs: '/user/edge/waiting-room',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -151,7 +158,7 @@ angular
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/edge/templates',
|
||||
docs: '/user/edge/templates/application',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
||||
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||
|
||||
const ngModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
|
||||
@@ -13,7 +13,11 @@ import { StackType } from '@/react/common/stacks/types';
|
||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
|
||||
|
||||
export default class CreateEdgeStackViewController {
|
||||
/* @ngInject */
|
||||
@@ -73,7 +77,7 @@ export default class CreateEdgeStackViewController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset').Values>} templateAction
|
||||
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset/types').Values>} templateAction
|
||||
*/
|
||||
setTemplateValues(templateAction) {
|
||||
return this.$async(async () => {
|
||||
@@ -82,44 +86,52 @@ export default class CreateEdgeStackViewController {
|
||||
const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
|
||||
this.state.templateValues = newTemplateValues;
|
||||
if (newTemplateId !== oldTemplateId) {
|
||||
await this.onChangeTemplate(newTemplateValues.template);
|
||||
await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template);
|
||||
}
|
||||
|
||||
let definitions = [];
|
||||
if (this.state.templateValues.template) {
|
||||
definitions = this.state.templateValues.template.Variables;
|
||||
}
|
||||
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
|
||||
if (newTemplateValues.type === 'custom') {
|
||||
const definitions = this.state.templateValues.template.Variables;
|
||||
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
|
||||
|
||||
this.formValues.StackFileContent = newFile;
|
||||
this.formValues.StackFileContent = newFile;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onChangeTemplate(template) {
|
||||
onChangeTemplate(type, template) {
|
||||
return this.$async(async () => {
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.templateValues.template = template;
|
||||
this.state.templateValues.variables = getVariablesFieldDefaultValues(template.Variables);
|
||||
if (type === 'custom') {
|
||||
this.formValues = {
|
||||
...this.formValues,
|
||||
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
|
||||
...toGitFormModel(template.GitConfig),
|
||||
...(template.EdgeSettings
|
||||
? {
|
||||
PrePullImage: template.EdgeSettings.PrePullImage || false,
|
||||
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
|
||||
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
|
||||
...template.EdgeSettings.RelativePathSettings,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
|
||||
this.state.templateValues.file = fileContent;
|
||||
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
|
||||
this.state.templateValues.file = fileContent;
|
||||
}
|
||||
|
||||
this.formValues = {
|
||||
...this.formValues,
|
||||
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
|
||||
...toGitFormModel(template.GitConfig),
|
||||
...(template.EdgeSettings
|
||||
? {
|
||||
PrePullImage: template.EdgeSettings.PrePullImage || false,
|
||||
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
|
||||
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
|
||||
...template.EdgeSettings.RelativePathSettings,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (type === 'app') {
|
||||
this.formValues.StackFileContent = '';
|
||||
try {
|
||||
const fileContent = await fetchFilePreview(template.Id);
|
||||
this.formValues.StackFileContent = fileContent;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,13 +171,27 @@ export default class CreateEdgeStackViewController {
|
||||
}
|
||||
}
|
||||
|
||||
async preSelectTemplate(templateId) {
|
||||
/**
|
||||
*
|
||||
* @param {'app' | 'custom'} templateType
|
||||
* @param {number} templateId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async preSelectTemplate(templateType, templateId) {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.Method = 'template';
|
||||
const template = await getCustomTemplate(templateId);
|
||||
const template = await getTemplate(templateType, templateId);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setTemplateValues({ template });
|
||||
this.setTemplateValues({
|
||||
template,
|
||||
type: templateType,
|
||||
envVars: templateType === 'app' ? getAppVariablesDefaultValues(template.Env) : {},
|
||||
variables: templateType === 'custom' ? getVariablesFieldDefaultValues(template.Variables) : [],
|
||||
});
|
||||
} catch (e) {
|
||||
notifyError('Failed loading template', e);
|
||||
}
|
||||
@@ -179,9 +205,10 @@ export default class CreateEdgeStackViewController {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
||||
}
|
||||
|
||||
const templateId = this.$state.params.templateId;
|
||||
if (templateId) {
|
||||
this.preSelectTemplate(templateId);
|
||||
const templateId = parseInt(this.$state.params.templateId, 10);
|
||||
const templateType = this.$state.params.templateType;
|
||||
if (templateType && templateId && !Number.isNaN(templateId)) {
|
||||
this.preSelectTemplate(templateType, templateId);
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
@@ -198,6 +225,16 @@ export default class CreateEdgeStackViewController {
|
||||
createStack() {
|
||||
return this.$async(async () => {
|
||||
const name = this.formValues.Name;
|
||||
|
||||
if (!this.validateTemplate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let envVars = this.formValues.envVars;
|
||||
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
|
||||
envVars = [...envVars, ...Object.entries(this.state.templateValues.envVars).map(([key, value]) => ({ name: key, value }))];
|
||||
}
|
||||
|
||||
const method = getMethod(this.state.Method, this.state.templateValues.template);
|
||||
|
||||
if (!this.validateForm(method)) {
|
||||
@@ -206,7 +243,7 @@ export default class CreateEdgeStackViewController {
|
||||
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
await this.createStackByMethod(name, method);
|
||||
await this.createStackByMethod(name, method, envVars);
|
||||
|
||||
this.Notifications.success('Success', 'Stack successfully deployed');
|
||||
this.state.isEditorDirty = false;
|
||||
@@ -258,19 +295,19 @@ export default class CreateEdgeStackViewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
createStackByMethod(name, method) {
|
||||
createStackByMethod(name, method, envVars) {
|
||||
switch (method) {
|
||||
case 'editor':
|
||||
return this.createStackFromFileContent(name);
|
||||
return this.createStackFromFileContent(name, envVars);
|
||||
case 'upload':
|
||||
return this.createStackFromFileUpload(name);
|
||||
return this.createStackFromFileUpload(name, envVars);
|
||||
case 'repository':
|
||||
return this.createStackFromGitRepository(name);
|
||||
return this.createStackFromGitRepository(name, envVars);
|
||||
}
|
||||
}
|
||||
|
||||
createStackFromFileContent(name) {
|
||||
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
||||
createStackFromFileContent(name, envVars) {
|
||||
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||
|
||||
return this.EdgeStackService.createStackFromFileContent({
|
||||
name,
|
||||
@@ -282,8 +319,9 @@ export default class CreateEdgeStackViewController {
|
||||
});
|
||||
}
|
||||
|
||||
createStackFromFileUpload(name) {
|
||||
const { StackFile, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
||||
createStackFromFileUpload(name, envVars) {
|
||||
const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||
|
||||
return this.EdgeStackService.createStackFromFileUpload(
|
||||
{
|
||||
Name: name,
|
||||
@@ -296,8 +334,9 @@ export default class CreateEdgeStackViewController {
|
||||
);
|
||||
}
|
||||
|
||||
createStackFromGitRepository(name) {
|
||||
const { Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
||||
async createStackFromGitRepository(name, envVars) {
|
||||
const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||
|
||||
const repositoryOptions = {
|
||||
RepositoryURL: this.formValues.RepositoryURL,
|
||||
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
|
||||
@@ -306,6 +345,7 @@ export default class CreateEdgeStackViewController {
|
||||
RepositoryUsername: this.formValues.RepositoryUsername,
|
||||
RepositoryPassword: this.formValues.RepositoryPassword,
|
||||
TLSSkipVerify: this.formValues.TLSSkipVerify,
|
||||
CreatedFromCustomTemplateID: this.state.templateValues.template.Id,
|
||||
};
|
||||
return this.EdgeStackService.createStackFromGitRepository(
|
||||
{
|
||||
@@ -328,12 +368,26 @@ export default class CreateEdgeStackViewController {
|
||||
});
|
||||
}
|
||||
|
||||
validateTemplate() {
|
||||
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
|
||||
return Object.entries(this.state.templateValues.envVars).every(([, value]) => !!value);
|
||||
}
|
||||
|
||||
if (this.state.Method === 'template' && this.state.templateValues.type === 'custom') {
|
||||
return Object.entries(this.state.templateValues.variables).every(([, v]) => {
|
||||
return !!v.value;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
formIsInvalid() {
|
||||
return (
|
||||
this.form.$invalid ||
|
||||
!this.formValues.Groups.length ||
|
||||
(['template', 'editor'].includes(this.state.Method) && !this.formValues.StackFileContent) ||
|
||||
('upload' === this.state.Method && !this.formValues.StackFile)
|
||||
('upload' === this.state.Method && !this.formValues.StackFile) ||
|
||||
!this.validateTemplate()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -354,3 +408,25 @@ function getMethod(method, template) {
|
||||
}
|
||||
return 'editor';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {'app' | 'custom'} templateType
|
||||
* @param {number} templateId
|
||||
* @returns {Promise<import('@/react/portainer/templates/app-templates/view-model').TemplateViewModel | import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined>}
|
||||
*/
|
||||
async function getTemplate(templateType, templateId) {
|
||||
if (!['app', 'custom'].includes(templateType)) {
|
||||
notifyError('Invalid template type', `Invalid template type: ${templateType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (templateType === 'app') {
|
||||
const templatesResponse = await getAppTemplates();
|
||||
const template = templatesResponse.templates.find((t) => t.id === templateId);
|
||||
return new TemplateViewModel(template, templatesResponse.version);
|
||||
}
|
||||
|
||||
const template = await getCustomTemplate(templateId);
|
||||
return template;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
class DockerComposeFormController {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
on-change="($ctrl.onChangeFormValues)"
|
||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||
created-from-custom-template-id="($ctrl.state.templateValues.type === 'custom' ? $ctrl.state.templateValues.template.Id : 0)"
|
||||
docs-links
|
||||
></git-form>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +140,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/helm',
|
||||
docs: '/user/kubernetes/inspect-helm',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/services',
|
||||
docs: '/user/kubernetes/networking/services',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -166,7 +166,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/ingresses',
|
||||
docs: '/user/kubernetes/networking/ingresses',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -178,6 +178,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesIngressesCreateView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/networking/ingresses/add',
|
||||
},
|
||||
};
|
||||
|
||||
const ingressesEdit = {
|
||||
@@ -211,6 +214,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesCreateApplicationView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/applications/add',
|
||||
},
|
||||
};
|
||||
|
||||
const application = {
|
||||
@@ -221,6 +227,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'applicationDetailsView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/applications/inspect',
|
||||
},
|
||||
};
|
||||
|
||||
const applicationEdit = {
|
||||
@@ -231,6 +240,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesCreateApplicationView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/applications/edit',
|
||||
},
|
||||
};
|
||||
|
||||
const applicationConsole = {
|
||||
@@ -317,6 +329,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesCreateConfigMapView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/configurations/add-configmap',
|
||||
},
|
||||
};
|
||||
|
||||
const configMap = {
|
||||
@@ -346,6 +361,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesCreateSecretView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/configurations/add-secret',
|
||||
},
|
||||
};
|
||||
|
||||
const secret = {
|
||||
@@ -367,7 +385,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/cluster',
|
||||
docs: '/user/kubernetes/cluster/details',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -379,6 +397,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesNodeView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/cluster/node',
|
||||
},
|
||||
};
|
||||
|
||||
const nodeStats = {
|
||||
@@ -412,6 +433,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesDeployView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/applications/manifest',
|
||||
},
|
||||
};
|
||||
|
||||
const resourcePools = {
|
||||
@@ -435,6 +459,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesCreateNamespaceView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/namespaces/add',
|
||||
},
|
||||
};
|
||||
|
||||
const resourcePool = {
|
||||
@@ -445,6 +472,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesResourcePoolView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/namespaces/manage',
|
||||
},
|
||||
};
|
||||
|
||||
const resourcePoolAccess = {
|
||||
@@ -455,6 +485,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
component: 'kubernetesResourcePoolAccessView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/namespaces/access',
|
||||
},
|
||||
};
|
||||
|
||||
const volumes = {
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
<div class="toolBar !pt-0">
|
||||
<div class="w-full">
|
||||
<div class="form-group !h-[30px] min-w-[140px] float-right">
|
||||
<div class="form-group float-right !h-[30px] min-w-[140px] mr-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -140,7 +140,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-fit">
|
||||
<insights-box type="'slim'" header="'From 2.18 on, you can filter this view by namespace.'" insight-close-id="'k8s-namespace-filtering'"></insights-box>
|
||||
<helm-insights-box></helm-insights-box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,20 +169,22 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatab
|
||||
};
|
||||
|
||||
this.$onChanges = function (changes) {
|
||||
// when the table is visible, sync the show system setting with the stack show system setting
|
||||
if (changes.isVisible && changes.isVisible.currentValue) {
|
||||
const storedStacksSettings = DatatableService.getDataTableSettings('kubernetes.applications.stacks');
|
||||
if (storedStacksSettings && storedStacksSettings.state) {
|
||||
this.settings.showSystem = storedStacksSettings.state.showSystemResources;
|
||||
if (this.settingsLoaded) {
|
||||
// when the table is visible, sync the show system setting with the stack show system setting
|
||||
if (changes.isVisible && changes.isVisible.currentValue) {
|
||||
const storedStacksSettings = DatatableService.getDataTableSettings('kubernetes.applications.stacks');
|
||||
if (storedStacksSettings && storedStacksSettings.state) {
|
||||
this.settings.showSystem = storedStacksSettings.state.showSystemResources;
|
||||
DatatableService.setDataTableSettings(this.settingsKey, this.settings);
|
||||
}
|
||||
} else if (typeof this.isSystemResources !== 'undefined') {
|
||||
this.settings.showSystem = this.isSystemResources;
|
||||
DatatableService.setDataTableSettings(this.settingsKey, this.settings);
|
||||
}
|
||||
} else if (typeof this.isSystemResources !== 'undefined') {
|
||||
this.settings.showSystem = this.isSystemResources;
|
||||
DatatableService.setDataTableSettings(this.settingsKey, this.settings);
|
||||
this.state.namespace = this.namespace;
|
||||
this.updateNamespace();
|
||||
this.prepareTableFromDataset();
|
||||
}
|
||||
this.state.namespace = this.namespace;
|
||||
this.updateNamespace();
|
||||
this.prepareTableFromDataset();
|
||||
};
|
||||
|
||||
this.$onInit = function () {
|
||||
@@ -226,7 +228,7 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatab
|
||||
// make show system in sync with the stack show system settings
|
||||
const storedStacksSettings = DatatableService.getDataTableSettings('kubernetes.applications.stacks');
|
||||
if (storedStacksSettings && storedStacksSettings.state) {
|
||||
this.settings.showSystem = storedStacksSettings.state.showSystemResources;
|
||||
this.settings.showSystem = storedStacksSettings.state.showSystemResources || this.settings.showSystem;
|
||||
}
|
||||
|
||||
this.setSystemResources && this.setSystemResources(this.settings.showSystem);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
data-cy="k8sNamespace-namespaceSearchInput"
|
||||
aria-label="Search input"
|
||||
/>
|
||||
</div>
|
||||
<div class="actionBar !mr-0 !gap-3" ng-if="$ctrl.isAdmin">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- helm chart -->
|
||||
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)">
|
||||
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)" role="listitem">
|
||||
<div class="blocklist-item-box">
|
||||
<!-- helmchart-image -->
|
||||
<span class="shrink-0">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="datatable">
|
||||
<section class="datatable" aria-label="Helm charts">
|
||||
<div class="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
|
||||
<div class="toolBarTitle vertical-center">
|
||||
{{ $ctrl.titleText }}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<div class="searchBar vertical-center !mr-0">
|
||||
<pr-icon icon="'search'" class="searchIcon"></pr-icon>
|
||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search for a chart..." auto-focus ng-model-options="{ debounce: 300 }" />
|
||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" aria-label="Search input" />
|
||||
</div>
|
||||
<div class="w-1/5">
|
||||
<por-select
|
||||
@@ -21,7 +21,8 @@
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="mb-2 small text-muted"
|
||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via <a ui-sref="portainer.account">User settings - Helm repositories</a>.</div
|
||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
||||
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
||||
>
|
||||
<beta-alert
|
||||
is-html="true"
|
||||
@@ -29,7 +30,7 @@
|
||||
></beta-alert>
|
||||
</div>
|
||||
|
||||
<div class="blocklist !px-0">
|
||||
<div class="blocklist !px-0" role="list">
|
||||
<helm-templates-list-item
|
||||
ng-repeat="chart in allCharts = ($ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory)"
|
||||
model="chart"
|
||||
@@ -44,4 +45,4 @@
|
||||
</div>
|
||||
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<pr-icon icon="'plus'" class="vertical-center"></pr-icon>
|
||||
Show custom values
|
||||
</button>
|
||||
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues">
|
||||
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues" role="status">
|
||||
<pr-icon icon="'refresh-cw'" class="mr-1"></pr-icon>
|
||||
Loading values.yaml...
|
||||
</span>
|
||||
|
||||
@@ -38,6 +38,7 @@ class KubernetesConfigMapConverter {
|
||||
res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
|
||||
res.CreationDate = data.metadata.creationTimestamp;
|
||||
res.Yaml = yaml ? yaml.data : '';
|
||||
res.Labels = data.metadata.labels;
|
||||
|
||||
res.Data = _.concat(
|
||||
_.map(data.data, (value, key) => {
|
||||
@@ -98,6 +99,7 @@ class KubernetesConfigMapConverter {
|
||||
res.metadata.uid = data.Id;
|
||||
res.metadata.name = data.Name;
|
||||
res.metadata.namespace = data.Namespace;
|
||||
res.metadata.labels = data.Labels || {};
|
||||
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner;
|
||||
_.forEach(data.Data, (entry) => {
|
||||
if (entry.IsBinary) {
|
||||
|
||||
@@ -21,6 +21,7 @@ class KubernetesConfigurationConverter {
|
||||
if (secret.Annotations) {
|
||||
res.ServiceAccountName = secret.Annotations['kubernetes.io/service-account.name'];
|
||||
}
|
||||
res.Labels = secret.Labels;
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ class KubernetesConfigurationConverter {
|
||||
});
|
||||
res.data = res.Data;
|
||||
res.ConfigurationOwner = configMap.ConfigurationOwner;
|
||||
res.Labels = configMap.Labels;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ class KubernetesDaemonSetConverter {
|
||||
const res = new KubernetesDaemonSet();
|
||||
res.Namespace = formValues.ResourcePool.Namespace.Name;
|
||||
res.Name = formValues.Name;
|
||||
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
|
||||
if (formValues.StackName) {
|
||||
res.StackName = formValues.StackName;
|
||||
}
|
||||
res.ApplicationOwner = formValues.ApplicationOwner;
|
||||
res.ApplicationName = formValues.Name;
|
||||
res.ImageModel = formValues.ImageModel;
|
||||
|
||||
@@ -22,7 +22,9 @@ class KubernetesDeploymentConverter {
|
||||
const res = new KubernetesDeployment();
|
||||
res.Namespace = formValues.ResourcePool.Namespace.Name;
|
||||
res.Name = formValues.Name;
|
||||
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
|
||||
if (formValues.StackName) {
|
||||
res.StackName = formValues.StackName;
|
||||
}
|
||||
res.ApplicationOwner = formValues.ApplicationOwner;
|
||||
res.ApplicationName = formValues.Name;
|
||||
res.ReplicaCount = formValues.ReplicaCount;
|
||||
|
||||
@@ -39,6 +39,7 @@ class KubernetesSecretConverter {
|
||||
res.metadata.name = secret.Name;
|
||||
res.metadata.namespace = secret.Namespace;
|
||||
res.type = secret.Type;
|
||||
res.metadata.labels = secret.Labels || {};
|
||||
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner;
|
||||
|
||||
let annotation = '';
|
||||
@@ -67,6 +68,7 @@ class KubernetesSecretConverter {
|
||||
res.Name = payload.metadata.name;
|
||||
res.Namespace = payload.metadata.namespace;
|
||||
res.Type = payload.type;
|
||||
res.Labels = payload.metadata.labels || {};
|
||||
res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
|
||||
res.CreationDate = payload.metadata.creationTimestamp;
|
||||
res.Annotations = payload.metadata.annotations;
|
||||
|
||||
@@ -24,7 +24,9 @@ class KubernetesStatefulSetConverter {
|
||||
const res = new KubernetesStatefulSet();
|
||||
res.Namespace = formValues.ResourcePool.Namespace.Name;
|
||||
res.Name = formValues.Name;
|
||||
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
|
||||
if (formValues.StackName) {
|
||||
res.StackName = formValues.StackName;
|
||||
}
|
||||
res.ApplicationOwner = formValues.ApplicationOwner;
|
||||
res.ApplicationName = formValues.Name;
|
||||
res.ReplicaCount = formValues.ReplicaCount;
|
||||
|
||||
@@ -37,6 +37,9 @@ function config($stateRegistryProvider) {
|
||||
params: {
|
||||
fileContent: '',
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/templates/add',
|
||||
},
|
||||
};
|
||||
|
||||
const customTemplatesEdit = {
|
||||
|
||||
@@ -21,6 +21,7 @@ const _KubernetesConfigMap = Object.freeze({
|
||||
Yaml: '',
|
||||
ConfigurationOwner: '',
|
||||
Data: [],
|
||||
Labels: {},
|
||||
});
|
||||
|
||||
export class KubernetesConfigMap {
|
||||
|
||||
@@ -14,6 +14,7 @@ const _KubernetesConfigurationFormValues = Object.freeze({
|
||||
IsSimple: true,
|
||||
ServiceAccountName: '',
|
||||
Type: KubernetesSecretTypeOptions.OPAQUE.value,
|
||||
Labels: {},
|
||||
});
|
||||
|
||||
export class KubernetesConfigurationFormValues {
|
||||
|
||||
@@ -12,6 +12,7 @@ const _KubernetesApplicationSecret = Object.freeze({
|
||||
Data: [],
|
||||
SecretType: '',
|
||||
Annotations: [],
|
||||
Labels: {},
|
||||
});
|
||||
|
||||
export class KubernetesApplicationSecret {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
|
||||
import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable';
|
||||
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
|
||||
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
|
||||
import { StackNameLabelInsight } from '@/react/kubernetes/DeployView/StackName/StackNameLabelInsight';
|
||||
import { SecretsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection';
|
||||
import { configurationsValidationSchema } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/configurationValidationSchema';
|
||||
import { ConfigMapsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection';
|
||||
@@ -58,6 +59,7 @@ import { deploymentTypeValidation } from '@/react/kubernetes/applications/compon
|
||||
import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection';
|
||||
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
|
||||
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
|
||||
import { HelmInsightsBox } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/HelmInsightsBox';
|
||||
|
||||
import { applicationsModule } from './applications';
|
||||
|
||||
@@ -88,6 +90,7 @@ export const ngModule = angular
|
||||
'value',
|
||||
])
|
||||
)
|
||||
.component('helmInsightsBox', r2a(HelmInsightsBox, []))
|
||||
.component(
|
||||
'namespaceAccessUsersSelector',
|
||||
r2a(NamespaceAccessUsersSelector, [
|
||||
@@ -139,9 +142,13 @@ export const ngModule = angular
|
||||
),
|
||||
{ stackName: 'setStackName' }
|
||||
),
|
||||
['setStackName', 'stackName', 'stacks', 'inputClassName']
|
||||
['setStackName', 'stackName', 'stacks', 'inputClassName', 'textTip']
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'stackNameLabelInsight',
|
||||
r2a(withUIRouter(withCurrentUser(StackNameLabelInsight)), [])
|
||||
)
|
||||
.component(
|
||||
'editYamlFormSection',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(EditYamlFormSection))), [
|
||||
|
||||
@@ -82,10 +82,12 @@ class KubernetesConfigurationService {
|
||||
if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) {
|
||||
const configMap = KubernetesConfigMapConverter.configurationFormValuesToConfigMap(formValues);
|
||||
configMap.ConfigurationOwner = configuration.ConfigurationOwner;
|
||||
configMap.Labels = configuration.Labels;
|
||||
await this.KubernetesConfigMapService.update(configMap);
|
||||
} else {
|
||||
const secret = KubernetesSecretConverter.configurationFormValuesToSecret(formValues);
|
||||
secret.ConfigurationOwner = configuration.ConfigurationOwner;
|
||||
secret.Labels = configuration.Labels;
|
||||
await this.KubernetesSecretService.update(secret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +135,11 @@
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="ctrl.updateApplicationViaWebEditor()"
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress"
|
||||
ng-disabled="!kubernetesApplicationCreationForm.$valid || !ctrl.state.isEditorDirty || ctrl.state.updateWebEditorInProgress"
|
||||
ng-disabled="ctrl.isUpdateApplicationViaWebEditorButtonDisabled() || !kubernetesApplicationCreationForm.$valid"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="ctrl.state.updateWebEditorInProgress"
|
||||
>
|
||||
<span ng-show="!ctrl.state.updateWebEditorInProgress">Update the application</span>
|
||||
<span ng-show="!ctrl.state.updateWebEditorInProgress">Update application</span>
|
||||
<span ng-show="ctrl.state.updateWebEditorInProgress">Update in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -169,6 +169,7 @@
|
||||
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
||||
stack-name="ctrl.formValues.StackName"
|
||||
set-stack-name="(ctrl.onChangeStackName)"
|
||||
text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'"
|
||||
stacks="ctrl.stacks"
|
||||
input-class-name="'col-lg-10 col-sm-9'"
|
||||
></kube-stack-name>
|
||||
@@ -179,6 +180,7 @@
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT"
|
||||
stack="ctrl.stack"
|
||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||
stack-name="ctrl.formValues.StackName"
|
||||
></kubernetes-redeploy-app-git-form>
|
||||
<!-- #endregion -->
|
||||
|
||||
@@ -226,9 +228,10 @@
|
||||
<div ng-if="ctrl.formValues.ResourcePool">
|
||||
<!-- #region STACK -->
|
||||
<kube-stack-name
|
||||
ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
||||
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality"
|
||||
stack-name="ctrl.formValues.StackName"
|
||||
set-stack-name="(ctrl.onChangeStackName)"
|
||||
text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'"
|
||||
stacks="ctrl.stacks"
|
||||
input-class-name="'col-lg-10 col-sm-9'"
|
||||
></kube-stack-name>
|
||||
@@ -287,7 +290,7 @@
|
||||
min-cpu-limit="ctrl.state.sliders.cpu.min"
|
||||
max-memory-limit="ctrl.state.sliders.memory.max"
|
||||
max-cpu-limit="ctrl.state.sliders.cpu.max"
|
||||
validation-data="{maxMemoryLimit: ctrl.state.sliders.memory.max, maxCpuLimit: ctrl.state.sliders.cpu.max, isEnvironmentAdmin: ctrl.isAdmin, nodeLimits: ctrl.nodesLimits.nodesLimits}"
|
||||
validation-data="{isExistingCPUReservationUnchanged: ctrl.state.isExistingCPUReservationUnchanged, isExistingMemoryReservationUnchanged: ctrl.state.isExistingMemoryReservationUnchanged, maxMemoryLimit: ctrl.state.sliders.memory.max, maxCpuLimit: ctrl.state.sliders.cpu.max, isEnvironmentAdmin: ctrl.isAdmin, nodeLimits: ctrl.nodesLimits.nodesLimits}"
|
||||
resource-quota-capacity-exceeded="ctrl.resourceQuotaCapacityExceeded()"
|
||||
></resource-reservation-form-section>
|
||||
|
||||
@@ -403,7 +406,7 @@
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="ctrl.updateApplicationViaWebEditor()"
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress"
|
||||
ng-disabled="!kubernetesApplicationCreationForm.$valid || !ctrl.state.isEditorDirty || ctrl.state.updateWebEditorInProgress"
|
||||
ng-disabled="ctrl.isUpdateApplicationViaWebEditorButtonDisabled() || !kubernetesApplicationCreationForm.$valid"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="ctrl.state.updateWebEditorInProgress"
|
||||
>
|
||||
|
||||
@@ -38,6 +38,7 @@ class KubernetesCreateApplicationController {
|
||||
$async,
|
||||
$state,
|
||||
$timeout,
|
||||
$window,
|
||||
Notifications,
|
||||
Authentication,
|
||||
KubernetesResourcePoolService,
|
||||
@@ -58,6 +59,7 @@ class KubernetesCreateApplicationController {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$timeout = $timeout;
|
||||
this.$window = $window;
|
||||
this.Notifications = Notifications;
|
||||
this.Authentication = Authentication;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
@@ -121,6 +123,10 @@ class KubernetesCreateApplicationController {
|
||||
persistedFoldersUseExistingVolumes: false,
|
||||
pullImageValidity: false,
|
||||
nodePortServices: [],
|
||||
// when the namespace available resources changes, and the existing app not has a resource limit that exceeds whats available,
|
||||
// a validation message will be shown. isExistingCPUReservationUnchanged and isExistingMemoryReservationUnchanged (with available resources being exceeded) is used to decide whether to show the message or not.
|
||||
isExistingCPUReservationUnchanged: false,
|
||||
isExistingMemoryReservationUnchanged: false,
|
||||
};
|
||||
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
@@ -157,6 +163,7 @@ class KubernetesCreateApplicationController {
|
||||
this.refreshReactComponent = this.refreshReactComponent.bind(this);
|
||||
this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this);
|
||||
this.canSupportSharedAccess = this.canSupportSharedAccess.bind(this);
|
||||
this.isUpdateApplicationViaWebEditorButtonDisabled = this.isUpdateApplicationViaWebEditorButtonDisabled.bind(this);
|
||||
|
||||
this.$scope.$watch(
|
||||
() => this.formValues,
|
||||
@@ -233,6 +240,7 @@ class KubernetesCreateApplicationController {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.DataAccessPolicy = value;
|
||||
this.resetDeploymentType();
|
||||
this.updateApplicationType();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,7 +263,7 @@ class KubernetesCreateApplicationController {
|
||||
{ stackFile: this.stackFileContent, stackName: this.formValues.StackName }
|
||||
);
|
||||
this.state.isEditorDirty = false;
|
||||
await this.$state.reload(this.$state.current);
|
||||
this.$window.location.reload();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Failed redeploying application');
|
||||
} finally {
|
||||
@@ -289,17 +297,6 @@ class KubernetesCreateApplicationController {
|
||||
/* #region AUTO SCALER UI MANAGEMENT */
|
||||
onAutoScaleChange(values) {
|
||||
return this.$async(async () => {
|
||||
// when enabling the auto scaler, set the default values
|
||||
if (!this.oldFormValues.AutoScaler.isUsed && values.isUsed) {
|
||||
this.formValues.AutoScaler = {
|
||||
isUsed: values.isUsed,
|
||||
minReplicas: 1,
|
||||
maxReplicas: 3,
|
||||
targetCpuUtilizationPercentage: 50,
|
||||
};
|
||||
return;
|
||||
}
|
||||
// otherwise, just update the values
|
||||
this.formValues.AutoScaler = values;
|
||||
|
||||
// reset it to previous form values if the user disables the auto scaler
|
||||
@@ -521,6 +518,13 @@ class KubernetesCreateApplicationController {
|
||||
return this.$async(async () => {
|
||||
this.formValues.MemoryLimit = values.memoryLimit;
|
||||
this.formValues.CpuLimit = values.cpuLimit;
|
||||
|
||||
if (this.oldFormValues.CpuLimit !== this.formValues.CpuLimit && this.state.isExistingCPUReservationUnchanged) {
|
||||
this.state.isExistingCPUReservationUnchanged = false;
|
||||
}
|
||||
if (this.oldFormValues.MemoryLimit !== this.formValues.MemoryLimit && this.state.isExistingMemoryReservationUnchanged) {
|
||||
this.state.isExistingMemoryReservationUnchanged = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -643,6 +647,10 @@ class KubernetesCreateApplicationController {
|
||||
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable;
|
||||
}
|
||||
|
||||
isUpdateApplicationViaWebEditorButtonDisabled() {
|
||||
return (this.savedFormValues.StackName === this.formValues.StackName && !this.state.isEditorDirty) || this.state.updateWebEditorInProgress;
|
||||
}
|
||||
|
||||
isExternalApplication() {
|
||||
if (this.application) {
|
||||
return KubernetesApplicationHelper.isExternalApplication(this.application);
|
||||
@@ -1066,6 +1074,13 @@ class KubernetesCreateApplicationController {
|
||||
this.originalServicePorts = structuredClone(this.formValues.Services.flatMap((service) => service.Ports));
|
||||
this.originalIngressPaths = structuredClone(this.originalServicePorts.flatMap((port) => port.ingressPaths).filter((ingressPath) => ingressPath.Host));
|
||||
|
||||
if (this.formValues.CpuLimit) {
|
||||
this.state.isExistingCPUReservationUnchanged = true;
|
||||
}
|
||||
if (this.formValues.MemoryLimit) {
|
||||
this.state.isExistingMemoryReservationUnchanged = true;
|
||||
}
|
||||
|
||||
if (this.application.ApplicationKind) {
|
||||
this.state.appType = KubernetesDeploymentTypes[this.application.ApplicationKind.toUpperCase()];
|
||||
if (this.application.ApplicationKind === KubernetesDeploymentTypes.URL) {
|
||||
|
||||
@@ -159,6 +159,7 @@ class KubernetesConfigMapController {
|
||||
this.formValues.Type = this.configuration.Type;
|
||||
this.formValues.Kind = this.configuration.Kind;
|
||||
this.oldDataYaml = this.formValues.DataYaml;
|
||||
this.formValues.Labels = this.configuration.Labels;
|
||||
|
||||
return this.configuration;
|
||||
} catch (err) {
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="configuration_name"
|
||||
name="configuration_name"
|
||||
ng-model="ctrl.formValues.Name"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/"
|
||||
|
||||
@@ -155,6 +155,7 @@ class KubernetesSecretController {
|
||||
this.formValues.Type = this.configuration.Type;
|
||||
this.formValues.Kind = this.configuration.Kind;
|
||||
this.oldDataYaml = this.formValues.DataYaml;
|
||||
this.formValues.Labels = this.configuration.Labels;
|
||||
|
||||
return this.configuration;
|
||||
} catch (err) {
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
ng-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"
|
||||
class="form-control"
|
||||
ng-model="ctrl.formValues.Namespace"
|
||||
ng-change="ctrl.onChangeNamespace()"
|
||||
ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces"
|
||||
></select>
|
||||
<span ng-if="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" class="small text-muted pt-[7px]"
|
||||
@@ -85,12 +86,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<kube-stack-name
|
||||
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"
|
||||
stack-name="ctrl.formValues.StackName"
|
||||
set-stack-name="(ctrl.setStackName)"
|
||||
is-admin="ctrl.currentUser.isAdmin"
|
||||
></kube-stack-name>
|
||||
<div ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
|
||||
<div class="w-fit mb-4">
|
||||
<stack-name-label-insight></stack-name-label-insight>
|
||||
</div>
|
||||
<kube-stack-name stack-name="ctrl.formValues.StackName" set-stack-name="(ctrl.setStackName)" stacks="ctrl.stacks"></kube-stack-name>
|
||||
</div>
|
||||
<!-- !namespace -->
|
||||
|
||||
<!-- repository -->
|
||||
@@ -105,7 +106,7 @@
|
||||
deploy-method="{{ ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE ? 'compose' : 'manifest' }}"
|
||||
base-webhook-url="{{ ctrl.state.baseWebhookUrl }}"
|
||||
webhook-id="{{ ctrl.state.webhookId }}"
|
||||
webhooks-docs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
|
||||
webhooks-docs="/user/kubernetes/applications/webhooks"
|
||||
></git-form>
|
||||
<!-- !repository -->
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templat
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService) {
|
||||
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
@@ -24,6 +24,7 @@ class KubernetesDeployController {
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.StackService = StackService;
|
||||
this.CustomTemplateService = CustomTemplateService;
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
|
||||
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
|
||||
@@ -33,7 +34,7 @@ class KubernetesDeployController {
|
||||
{ ...git, value: KubernetesDeployBuildMethods.GIT },
|
||||
{ ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR },
|
||||
{ ...url, value: KubernetesDeployBuildMethods.URL },
|
||||
{ ...customTemplate, description: 'Use custom template', value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
|
||||
{ ...customTemplate, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
|
||||
{ ...helm, value: KubernetesDeployBuildMethods.HELM },
|
||||
];
|
||||
|
||||
@@ -78,6 +79,8 @@ class KubernetesDeployController {
|
||||
Name: '',
|
||||
};
|
||||
|
||||
this.stacks = [];
|
||||
|
||||
this.ManifestDeployTypes = KubernetesDeployManifestTypes;
|
||||
this.BuildMethods = KubernetesDeployBuildMethods;
|
||||
|
||||
@@ -92,6 +95,15 @@ class KubernetesDeployController {
|
||||
this.onChangeDeployType = this.onChangeDeployType.bind(this);
|
||||
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
|
||||
this.setStackName = this.setStackName.bind(this);
|
||||
this.onChangeNamespace = this.onChangeNamespace.bind(this);
|
||||
}
|
||||
|
||||
onChangeNamespace() {
|
||||
return this.$async(async () => {
|
||||
const applications = await this.KubernetesApplicationService.get(this.formValues.Namespace);
|
||||
const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== '');
|
||||
this.stacks = _.uniq(stacks);
|
||||
});
|
||||
}
|
||||
|
||||
onSelectHelmChart(chart) {
|
||||
@@ -377,6 +389,7 @@ class KubernetesDeployController {
|
||||
}
|
||||
}
|
||||
|
||||
this.onChangeNamespace();
|
||||
this.state.viewReady = true;
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const _paq = (window._paq = window._paq || []);
|
||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||
_paq.push(['enableLinkTracking']);
|
||||
|
||||
var u = 'https://portainer-ce.matomo.cloud/';
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { reactModule } from './react';
|
||||
import { sidebarModule } from './react/views/sidebar';
|
||||
import environmentsModule from './environments';
|
||||
import { helpersModule } from './helpers';
|
||||
import { AccessHeaders, requiresAuthHook } from './authorization-guard';
|
||||
|
||||
async function initAuthentication(Authentication) {
|
||||
return await Authentication.init();
|
||||
@@ -60,6 +61,9 @@ angular
|
||||
component: 'sidebar',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
access: AccessHeaders.Restricted,
|
||||
},
|
||||
};
|
||||
|
||||
var endpointRoot = {
|
||||
@@ -122,6 +126,16 @@ angular
|
||||
},
|
||||
};
|
||||
|
||||
const createHelmRepository = {
|
||||
name: 'portainer.account.createHelmRepository',
|
||||
url: '/helm-repository/new',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createHelmRepositoryView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var authentication = {
|
||||
name: 'portainer.auth',
|
||||
url: '/auth',
|
||||
@@ -136,6 +150,9 @@ angular
|
||||
},
|
||||
'sidebar@': {},
|
||||
},
|
||||
data: {
|
||||
access: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const logout = {
|
||||
@@ -152,6 +169,9 @@ angular
|
||||
},
|
||||
'sidebar@': {},
|
||||
},
|
||||
data: {
|
||||
access: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
var endpoints = {
|
||||
@@ -163,7 +183,7 @@ angular
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/environments',
|
||||
docs: '/admin/environments/environments',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -200,6 +220,9 @@ angular
|
||||
component: 'edgeAutoCreateScriptView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/environments/aeec',
|
||||
},
|
||||
};
|
||||
|
||||
var addFDOProfile = {
|
||||
@@ -256,6 +279,7 @@ angular
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/environments/groups',
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -312,6 +336,9 @@ angular
|
||||
views: {
|
||||
'sidebar@': {},
|
||||
},
|
||||
data: {
|
||||
access: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
var initAdmin = {
|
||||
@@ -336,6 +363,7 @@ angular
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/registries',
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -347,6 +375,9 @@ angular
|
||||
component: 'editRegistry',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/registries/edit',
|
||||
},
|
||||
};
|
||||
|
||||
const registryCreation = {
|
||||
@@ -357,6 +388,9 @@ angular
|
||||
component: 'createRegistry',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/registries/add',
|
||||
},
|
||||
};
|
||||
|
||||
var settings = {
|
||||
@@ -369,6 +403,7 @@ angular
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/settings',
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -410,6 +445,7 @@ angular
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/environments/tags',
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -423,7 +459,8 @@ angular
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/users',
|
||||
docs: '/admin/user/users',
|
||||
access: AccessHeaders.Restricted, // allow for team leaders
|
||||
},
|
||||
};
|
||||
|
||||
@@ -438,16 +475,6 @@ angular
|
||||
},
|
||||
};
|
||||
|
||||
const createHelmRepository = {
|
||||
name: 'portainer.account.createHelmRepository',
|
||||
url: '/helm-repository/new',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createHelmRepositoryView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(root);
|
||||
$stateRegistryProvider.register(endpointRoot);
|
||||
$stateRegistryProvider.register(portainer);
|
||||
@@ -481,7 +508,8 @@ angular
|
||||
$stateRegistryProvider.register(user);
|
||||
$stateRegistryProvider.register(createHelmRepository);
|
||||
},
|
||||
]);
|
||||
])
|
||||
.run(run);
|
||||
|
||||
function isTransitionRequiresAuthentication(transition) {
|
||||
const UNAUTHENTICATED_ROUTES = ['portainer.logout', 'portainer.auth'];
|
||||
@@ -492,3 +520,8 @@ function isTransitionRequiresAuthentication(transition) {
|
||||
const nextTransitionName = nextTransition ? nextTransition.name : '';
|
||||
return !UNAUTHENTICATED_ROUTES.some((route) => nextTransitionName.startsWith(route));
|
||||
}
|
||||
|
||||
/* @ngInject */
|
||||
function run($transitions) {
|
||||
requiresAuthHook($transitions);
|
||||
}
|
||||
|
||||
118
app/portainer/authorization-guard.test.ts
Normal file
118
app/portainer/authorization-guard.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
StateDeclaration,
|
||||
StateService,
|
||||
Transition,
|
||||
} from '@uirouter/angularjs';
|
||||
|
||||
import { checkAuthorizations } from './authorization-guard';
|
||||
import { IAuthenticationService } from './services/types';
|
||||
|
||||
describe('checkAuthorizations', () => {
|
||||
let authService = {
|
||||
init: vi.fn(),
|
||||
isPureAdmin: vi.fn(),
|
||||
isAdmin: vi.fn(),
|
||||
hasAuthorizations: vi.fn(),
|
||||
getUserDetails: vi.fn(),
|
||||
isAuthenticated: vi.fn(),
|
||||
} satisfies IAuthenticationService;
|
||||
let transition: Transition;
|
||||
const stateTo: StateDeclaration = {
|
||||
data: {
|
||||
access: 'restricted',
|
||||
},
|
||||
};
|
||||
const $state = {
|
||||
target: vi.fn((t) => t),
|
||||
} as unknown as StateService;
|
||||
|
||||
beforeEach(() => {
|
||||
authService = {
|
||||
init: vi.fn(),
|
||||
isPureAdmin: vi.fn(),
|
||||
isAdmin: vi.fn(),
|
||||
hasAuthorizations: vi.fn(),
|
||||
getUserDetails: vi.fn(),
|
||||
isAuthenticated: vi.fn(),
|
||||
};
|
||||
|
||||
transition = {
|
||||
injector: vi.fn().mockReturnValue({
|
||||
get: vi.fn().mockReturnValue(authService),
|
||||
}),
|
||||
to: vi.fn().mockReturnValue(stateTo),
|
||||
router: {
|
||||
stateService: $state,
|
||||
} as Transition['router'],
|
||||
} as unknown as Transition;
|
||||
|
||||
stateTo.data.access = 'restricted';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined if access is not defined', async () => {
|
||||
stateTo.data.access = undefined;
|
||||
const result = await checkAuthorizations(transition);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if user is not authenticated and route access is defined', async () => {
|
||||
stateTo.data.access = 'something';
|
||||
authService.init.mockResolvedValue(false);
|
||||
|
||||
const result = await checkAuthorizations(transition);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return logout if access is "restricted"', async () => {
|
||||
const result = await checkAuthorizations(transition);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect($state.target).toHaveBeenCalledWith('portainer.logout');
|
||||
});
|
||||
|
||||
it('should return undefined if user is an admin and access is "admin"', async () => {
|
||||
authService.init.mockResolvedValue(true);
|
||||
authService.isPureAdmin.mockReturnValue(true);
|
||||
stateTo.data.access = 'admin';
|
||||
|
||||
const result = await checkAuthorizations(transition);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if user is an admin and access is "edge-admin"', async () => {
|
||||
authService.init.mockResolvedValue(true);
|
||||
authService.isAdmin.mockReturnValue(true);
|
||||
stateTo.data.access = 'edge-admin';
|
||||
|
||||
const result = await checkAuthorizations(transition);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if user has the required authorizations', async () => {
|
||||
authService.init.mockResolvedValue(true);
|
||||
authService.hasAuthorizations.mockReturnValue(true);
|
||||
stateTo.data.access = ['permission1', 'permission2'];
|
||||
|
||||
const result = await checkAuthorizations(transition);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should redirect to home if user does not have the required authorizations', async () => {
|
||||
authService.init.mockResolvedValue(true);
|
||||
authService.hasAuthorizations.mockReturnValue(false);
|
||||
stateTo.data.access = ['permission1', 'permission2'];
|
||||
|
||||
const result = await checkAuthorizations(transition);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect($state.target).toHaveBeenCalledWith('portainer.home');
|
||||
});
|
||||
});
|
||||
99
app/portainer/authorization-guard.ts
Normal file
99
app/portainer/authorization-guard.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Transition, TransitionService } from '@uirouter/angularjs';
|
||||
|
||||
import { IAuthenticationService } from './services/types';
|
||||
|
||||
export enum AccessHeaders {
|
||||
Restricted = 'restricted',
|
||||
Admin = 'admin',
|
||||
EdgeAdmin = 'edge-admin',
|
||||
}
|
||||
|
||||
type Authorizations = string[];
|
||||
type Access =
|
||||
| AccessHeaders.Restricted
|
||||
| AccessHeaders.Admin
|
||||
| AccessHeaders.EdgeAdmin
|
||||
| Authorizations;
|
||||
|
||||
export function requiresAuthHook(transitionService: TransitionService) {
|
||||
transitionService.onBefore({}, checkAuthorizations);
|
||||
}
|
||||
|
||||
// exported for tests
|
||||
export async function checkAuthorizations(transition: Transition) {
|
||||
const authService: IAuthenticationService = transition
|
||||
.injector()
|
||||
.get('Authentication');
|
||||
const stateTo = transition.to();
|
||||
const $state = transition.router.stateService;
|
||||
|
||||
const { access } = stateTo.data || {};
|
||||
if (!isAccess(access)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isLoggedIn = await authService.init();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
'User is not authenticated, redirecting to login, access:',
|
||||
access
|
||||
);
|
||||
return $state.target('portainer.logout');
|
||||
}
|
||||
|
||||
if (typeof access === 'string') {
|
||||
if (access === 'restricted') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (access === 'admin') {
|
||||
if (authService.isPureAdmin()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
'User is not an admin, redirecting to home, access:',
|
||||
access
|
||||
);
|
||||
return $state.target('portainer.home');
|
||||
}
|
||||
|
||||
if (access === 'edge-admin') {
|
||||
if (authService.isAdmin(true)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
'User is not an edge admin, redirecting to home, access:',
|
||||
access
|
||||
);
|
||||
return $state.target('portainer.home');
|
||||
}
|
||||
}
|
||||
|
||||
if (access.length > 0 && !authService.hasAuthorizations(access)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
'User does not have the required authorizations, redirecting to home'
|
||||
);
|
||||
return $state.target('portainer.home');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isAccess(access: unknown): access is Access {
|
||||
if (!access || (typeof access !== 'string' && !Array.isArray(access))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(access)) {
|
||||
return access.every((a) => typeof a === 'string');
|
||||
}
|
||||
|
||||
return ['restricted', 'admin', 'edge-admin'].includes(access);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
|
||||
|
||||
this.$onInit = $onInit;
|
||||
function $onInit() {
|
||||
var isAdmin = Authentication.isAdmin();
|
||||
var isAdmin = Authentication.isPureAdmin();
|
||||
ctrl.isAdmin = isAdmin;
|
||||
|
||||
if (isAdmin) {
|
||||
|
||||
@@ -65,7 +65,7 @@ export default class GitFormAuthFieldsetController {
|
||||
);
|
||||
|
||||
this.errors = await validateForm<GitAuthModel>(
|
||||
() => gitAuthValidation(this.gitCredentials, isAuthEdit),
|
||||
() => gitAuthValidation(this.gitCredentials, isAuthEdit, false),
|
||||
value
|
||||
);
|
||||
if (this.errors && Object.keys(this.errors).length > 0) {
|
||||
|
||||
@@ -24,6 +24,8 @@ export default class GitFormController {
|
||||
|
||||
onChange?: (value: GitFormModel) => void;
|
||||
|
||||
createdFromCustomTemplateId?: number;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$async: <T>(fn: () => Promise<T>) => Promise<T>,
|
||||
@@ -47,15 +49,26 @@ export default class GitFormController {
|
||||
...newValues,
|
||||
};
|
||||
this.onChange?.(value);
|
||||
await this.runGitFormValidation(value);
|
||||
|
||||
const isCreatedFromCustomTemplate =
|
||||
!!this.createdFromCustomTemplateId &&
|
||||
this.createdFromCustomTemplateId > 0;
|
||||
await this.runGitFormValidation(value, isCreatedFromCustomTemplate);
|
||||
}
|
||||
|
||||
async runGitFormValidation(value: GitFormModel) {
|
||||
async runGitFormValidation(
|
||||
value: GitFormModel,
|
||||
isCreatedFromCustomTemplate: boolean
|
||||
) {
|
||||
return this.$async(async () => {
|
||||
this.errors = {};
|
||||
this.gitForm?.$setValidity('gitForm', true, this.gitForm);
|
||||
|
||||
this.errors = await validateGitForm(this.gitCredentials, value);
|
||||
this.errors = await validateGitForm(
|
||||
this.gitCredentials,
|
||||
value,
|
||||
isCreatedFromCustomTemplate
|
||||
);
|
||||
if (this.errors && Object.keys(this.errors).length > 0) {
|
||||
this.gitForm?.$setValidity('gitForm', false, this.gitForm);
|
||||
}
|
||||
@@ -82,6 +95,9 @@ export default class GitFormController {
|
||||
throw new Error('GitFormController: value is required');
|
||||
}
|
||||
|
||||
await this.runGitFormValidation(this.value);
|
||||
const isCreatedFromCustomTemplate =
|
||||
!!this.createdFromCustomTemplateId &&
|
||||
this.createdFromCustomTemplateId > 0;
|
||||
await this.runGitFormValidation(this.value, isCreatedFromCustomTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const gitForm: IComponentOptions = {
|
||||
base-webhook-url="$ctrl.baseWebhookUrl"
|
||||
webhook-id="$ctrl.webhookId"
|
||||
webhooks-docs="$ctrl.webhooksDocs"
|
||||
created-from-custom-template-id="$ctrl.createdFromCustomTemplateId"
|
||||
errors="$ctrl.errors">
|
||||
</react-git-form>
|
||||
</ng-form>`,
|
||||
@@ -32,6 +33,7 @@ export const gitForm: IComponentOptions = {
|
||||
isAuthExplanationVisible: '<',
|
||||
webhookId: '@',
|
||||
webhooksDocs: '@',
|
||||
createdFromCustomTemplateId: '<',
|
||||
},
|
||||
controller,
|
||||
};
|
||||
|
||||
@@ -126,6 +126,10 @@ class KubernetesRedeployAppGitFormController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stack.Name !== this.stackName) {
|
||||
this.formValues.StackName = this.stackName;
|
||||
}
|
||||
|
||||
this.state.redeployInProgress = true;
|
||||
await this.StackService.updateKubeGit(this.stack.Id, this.stack.EndpointId, this.namespace, this.formValues);
|
||||
this.Notifications.success('Success', 'Pulled and redeployed stack successfully');
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
is-force-pull-visible="false"
|
||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||
webhooks-docs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
|
||||
webhooks-docs="/user/kubernetes/applications/webhooks"
|
||||
></git-form-auto-update-fieldset>
|
||||
<time-window-display></time-window-display>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const kubernetesRedeployAppGitForm = {
|
||||
bindings: {
|
||||
stack: '<',
|
||||
namespace: '<',
|
||||
stackName: '<',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
is-force-pull-visible="$ctrl.stack.Type !== 3"
|
||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||
webhooks-docs="https://docs.portainer.io/user/docker/stacks/webhooks"
|
||||
webhooks-docs="/user/docker/stacks/webhooks"
|
||||
></git-form-auto-update-fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AccessHeaders } from '../authorization-guard';
|
||||
import { rolesView } from './views/roles';
|
||||
import { accessViewer } from './components/access-viewer';
|
||||
import { accessViewerDatatable } from './components/access-viewer/access-viewer-datatable';
|
||||
@@ -28,7 +29,8 @@ function config($stateRegistryProvider) {
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/users/roles',
|
||||
docs: '/admin/user/roles',
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export const ngModule = angular
|
||||
'disabledTypes',
|
||||
'fixedCategories',
|
||||
'storageKey',
|
||||
'templateLinkParams',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -28,6 +28,7 @@ export const gitFormModule = angular
|
||||
'baseWebhookUrl',
|
||||
'webhookId',
|
||||
'webhooksDocs',
|
||||
'createdFromCustomTemplateId',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
@@ -42,7 +43,7 @@ export const gitFormModule = angular
|
||||
)
|
||||
.component(
|
||||
'reactGitFormAutoUpdateFieldset',
|
||||
r2a(AutoUpdateFieldset, [
|
||||
r2a(withUIRouter(withReactQuery(AutoUpdateFieldset)), [
|
||||
'value',
|
||||
'onChange',
|
||||
'environmentType',
|
||||
@@ -69,6 +70,7 @@ export const gitFormModule = angular
|
||||
'model',
|
||||
'onChange',
|
||||
'stackId',
|
||||
'createdFromCustomTemplateId',
|
||||
'value',
|
||||
'isUrlValid',
|
||||
])
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Slider } from '@@/form-components/Slider';
|
||||
import { TagButton } from '@@/TagButton';
|
||||
import { BETeaserButton } from '@@/BETeaserButton';
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
import { HelpLink } from '@@/HelpLink';
|
||||
|
||||
import { fileUploadField } from './file-upload-field';
|
||||
import { switchField } from './switch-field';
|
||||
@@ -126,6 +127,14 @@ export const ngModule = angular
|
||||
'reactQueryDevTools',
|
||||
r2a(withReactQuery(ReactQueryDevtoolsWrapper), [])
|
||||
)
|
||||
.component(
|
||||
'helpLink',
|
||||
r2a(withUIRouter(withReactQuery(HelpLink)), [
|
||||
'docLink',
|
||||
'target',
|
||||
'children',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'dashboardItem',
|
||||
r2a(DashboardItem, [
|
||||
@@ -184,6 +193,7 @@ export const ngModule = angular
|
||||
'components',
|
||||
'isLoading',
|
||||
'noOptionsMessage',
|
||||
'aria-label',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
@@ -196,6 +206,7 @@ export const ngModule = angular
|
||||
'onChange',
|
||||
'visibleTooltip',
|
||||
'dataCy',
|
||||
'disabled',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -25,7 +25,7 @@ export const settingsModule = angular
|
||||
)
|
||||
.component(
|
||||
'applicationSettingsPanel',
|
||||
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
|
||||
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess', 'settings'])
|
||||
)
|
||||
.component(
|
||||
'sslSettingsPanel',
|
||||
@@ -38,5 +38,5 @@ export const settingsModule = angular
|
||||
)
|
||||
.component(
|
||||
'kubeSettingsPanel',
|
||||
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), [])
|
||||
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), ['settings'])
|
||||
).name;
|
||||
|
||||
@@ -10,7 +10,7 @@ export const switchField = r2a(SwitchField, [
|
||||
'name',
|
||||
'labelClass',
|
||||
'fieldClass',
|
||||
'dataCy',
|
||||
'data-cy',
|
||||
'disabled',
|
||||
'onChange',
|
||||
'featureId',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { r2a } from '@/react-tools/react2angular';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { AccessHeaders } from '@/portainer/authorization-guard';
|
||||
|
||||
export const teamsModule = angular
|
||||
.module('portainer.app.teams', [])
|
||||
@@ -30,7 +31,8 @@ function config($stateRegistryProvider: StateRegistry) {
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/users/teams',
|
||||
docs: '/admin/user/teams',
|
||||
access: AccessHeaders.Restricted, // allow for team leaders
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ function config($stateRegistryProvider: StateRegistry) {
|
||||
component: 'updateSchedulesListView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/environments/update',
|
||||
},
|
||||
});
|
||||
|
||||
$stateRegistryProvider.register({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { AccessHeaders } from '@/portainer/authorization-guard';
|
||||
|
||||
export const wizardModule = angular
|
||||
.module('portainer.app.react.views.wizard', [])
|
||||
@@ -42,6 +43,9 @@ function config($stateRegistryProvider: StateRegistry) {
|
||||
component: 'wizardMainView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
});
|
||||
|
||||
$stateRegistryProvider.register({
|
||||
|
||||
@@ -461,6 +461,7 @@ angular.module('portainer.app').factory('StackService', [
|
||||
RepositoryAuthentication: gitConfig.RepositoryAuthentication,
|
||||
RepositoryUsername: gitConfig.RepositoryUsername,
|
||||
RepositoryPassword: gitConfig.RepositoryPassword,
|
||||
StackName: gitConfig.StackName,
|
||||
}
|
||||
).$promise;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import { hasAuthorizations as useUserHasAuthorization } from '@/react/hooks/useUser';
|
||||
import { getCurrentUser } from '../users/queries/useLoadCurrentUser';
|
||||
import * as userHelpers from '../users/user.helpers';
|
||||
import { clear as clearSessionStorage } from './session-storage';
|
||||
|
||||
const DEFAULT_USER = 'admin';
|
||||
const DEFAULT_PASSWORD = 'K7yJPP5qNK4hf1QsRnfV';
|
||||
|
||||
angular.module('portainer.app').factory('Authentication', [
|
||||
'$async',
|
||||
'$state',
|
||||
'Auth',
|
||||
'OAuth',
|
||||
'LocalStorage',
|
||||
'StateManager',
|
||||
'EndpointProvider',
|
||||
'ThemeManager',
|
||||
function AuthenticationFactory($async, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
|
||||
function AuthenticationFactory($async, $state, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
|
||||
'use strict';
|
||||
|
||||
var service = {};
|
||||
var user = {};
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.login = loginAsync;
|
||||
}
|
||||
|
||||
service.init = init;
|
||||
service.OAuthLogin = OAuthLogin;
|
||||
service.login = login;
|
||||
service.logout = logout;
|
||||
service.isAuthenticated = isAuthenticated;
|
||||
service.getUserDetails = getUserDetails;
|
||||
service.isAdmin = isAdmin;
|
||||
return {
|
||||
init,
|
||||
OAuthLogin,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
getUserDetails,
|
||||
isAdmin,
|
||||
isEdgeAdmin,
|
||||
isPureAdmin,
|
||||
hasAuthorizations,
|
||||
redirectIfUnauthorized,
|
||||
};
|
||||
|
||||
async function initAsync() {
|
||||
try {
|
||||
@@ -120,14 +130,48 @@ angular.module('portainer.app').factory('Authentication', [
|
||||
return login(DEFAULT_USER, DEFAULT_PASSWORD);
|
||||
}
|
||||
|
||||
function isAdmin() {
|
||||
return !!user && user.role === 1;
|
||||
// To avoid creating divergence between CE and EE
|
||||
// isAdmin checks if the user is a portainer admin or edge admin
|
||||
|
||||
function isEdgeAdmin(noEnvScope = false) {
|
||||
const environment = EndpointProvider.currentEndpoint();
|
||||
return userHelpers.isEdgeAdmin({ Role: user.role }, noEnvScope ? undefined : environment);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.login = loginAsync;
|
||||
/**
|
||||
* @deprecated use Authentication.isAdmin instead
|
||||
*/
|
||||
function isAdmin(noEnvScope = false) {
|
||||
return isEdgeAdmin(noEnvScope);
|
||||
}
|
||||
|
||||
return service;
|
||||
// To avoid creating divergence between CE and EE
|
||||
// isPureAdmin checks if the user is portainer admin only
|
||||
function isPureAdmin() {
|
||||
return userHelpers.isPureAdmin({ Role: user.role });
|
||||
}
|
||||
|
||||
function hasAuthorizations(authorizations) {
|
||||
const endpointId = EndpointProvider.endpointID();
|
||||
|
||||
if (isEdgeAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return useUserHasAuthorization(
|
||||
{
|
||||
EndpointAuthorizations: user.endpointAuthorizations,
|
||||
},
|
||||
authorizations,
|
||||
endpointId
|
||||
);
|
||||
}
|
||||
|
||||
function redirectIfUnauthorized(authorizations) {
|
||||
const authorized = hasAuthorizations(authorizations);
|
||||
if (!authorized) {
|
||||
$state.go('portainer.home');
|
||||
}
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,17 @@ export interface StateManager {
|
||||
|
||||
export interface IAuthenticationService {
|
||||
getUserDetails(): { ID: number };
|
||||
isAuthenticated(): boolean;
|
||||
isAdmin(noEnvScope?: boolean): boolean;
|
||||
isPureAdmin(): boolean;
|
||||
hasAuthorizations(authorizations: string[]): boolean;
|
||||
|
||||
init(): Promise<boolean>;
|
||||
// OAuthLogin,
|
||||
// login,
|
||||
// logout,
|
||||
|
||||
// redirectIfUnauthorized,
|
||||
}
|
||||
|
||||
export type AsyncService = <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { NotificationsViewAngular } from '@/react/portainer/notifications/NotificationsView';
|
||||
import { AccessHeaders } from '../authorization-guard';
|
||||
import authLogsViewModule from './auth-logs-view';
|
||||
import activityLogsViewModule from './activity-logs-view';
|
||||
|
||||
@@ -18,6 +19,7 @@ function config($stateRegistryProvider) {
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/logs',
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,6 +33,7 @@ function config($stateRegistryProvider) {
|
||||
},
|
||||
data: {
|
||||
docs: '/admin/logs/activity',
|
||||
access: AccessHeaders.Admin,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { TeamRole, TeamMembership } from '@/react/portainer/users/teams/types';
|
||||
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { User, UserId } from './types';
|
||||
import { isAdmin } from './user.helpers';
|
||||
import { getUserMemberships, getUsers } from './user.service';
|
||||
|
||||
interface UseUserMembershipOptions<TSelect> {
|
||||
@@ -22,14 +22,21 @@ export function useUserMembership<TSelect = TeamMembership[]>(
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsTeamLeader(user: User) {
|
||||
export function useIsCurrentUserTeamLeader() {
|
||||
const { user } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
const query = useUserMembership(user.Id, {
|
||||
enabled: !isAdmin(user),
|
||||
enabled: !isAdminQuery.isLoading && !isAdminQuery.isAdmin,
|
||||
select: (memberships) =>
|
||||
memberships.some((membership) => membership.Role === TeamRole.Leader),
|
||||
});
|
||||
|
||||
return isAdmin(user) ? true : query.data;
|
||||
if (isAdminQuery.isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isAdminQuery.isAdmin ? true : !!query.data;
|
||||
}
|
||||
|
||||
export function useUsers<T = User[]>(
|
||||
|
||||
@@ -7,6 +7,7 @@ export { type UserId };
|
||||
export enum Role {
|
||||
Admin = 1,
|
||||
Standard,
|
||||
EdgeAdmin,
|
||||
}
|
||||
|
||||
interface AuthorizationMap {
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { Role, User } from './types';
|
||||
|
||||
export function filterNonAdministratorUsers(users: User[]) {
|
||||
return users.filter((user) => user.Role !== Role.Admin);
|
||||
return users.filter((user) => !isPureAdmin(user));
|
||||
}
|
||||
|
||||
export function isAdmin(user?: User): boolean {
|
||||
return !!user && user.Role === 1;
|
||||
type UserLike = Pick<User, 'Role'>;
|
||||
|
||||
// To avoid creating divergence between CE and EE
|
||||
// isAdmin checks if the user is portainer admin or edge admin
|
||||
export function isEdgeAdmin(
|
||||
user: UserLike | undefined,
|
||||
environment?: Pick<Environment, 'Type'> | null
|
||||
): boolean {
|
||||
return (
|
||||
isPureAdmin(user) ||
|
||||
(user?.Role === Role.EdgeAdmin &&
|
||||
(!environment || isEdgeEnvironment(environment.Type)))
|
||||
);
|
||||
}
|
||||
|
||||
// To avoid creating divergence between CE and EE
|
||||
// isPureAdmin checks only if the user is portainer admin
|
||||
// See bouncer.IsAdmin and bouncer.PureAdminAccess
|
||||
export function isPureAdmin(user?: UserLike): boolean {
|
||||
return !!user && user.Role === Role.Admin;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ class CustomTemplatesViewController {
|
||||
|
||||
const template = _.find(this.templates, { Id: templateId });
|
||||
|
||||
const isGit = template.GitConfig !== null;
|
||||
this.state.isEditorReadOnly = isGit;
|
||||
|
||||
try {
|
||||
this.state.templateContent = this.formValues.fileContent = await this.CustomTemplateService.customTemplateFile(template.Id, template.GitConfig !== null);
|
||||
} catch (err) {
|
||||
|
||||
@@ -131,48 +131,150 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !note -->
|
||||
|
||||
<box-selector slim="true" options="restoreOptions" value="formValues.restoreFormType" on-change="(onChangeRestoreType)" radio-name="'restore-type'"></box-selector>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted"> You can upload a backup file from your computer. </span>
|
||||
<div ng-if="formValues.restoreFormType === RESTORE_FORM_TYPES.FILE">
|
||||
<!-- note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted"> You can upload a backup file from your computer. </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !note -->
|
||||
<!-- select-file-input -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ngf-select
|
||||
accept=".gz,.encrypted"
|
||||
ngf-accept="'application/x-tar,application/x-gzip'"
|
||||
ng-model="formValues.BackupFile"
|
||||
auto-focus
|
||||
data-cy="init-selectBackupFileButton"
|
||||
>Select file</button
|
||||
>
|
||||
<span class="space-left vertical-center">
|
||||
{{ formValues.BackupFile.name }}
|
||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
|
||||
</span>
|
||||
<!-- !note -->
|
||||
<!-- select-file-input -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ngf-select
|
||||
accept=".gz,.encrypted"
|
||||
ngf-accept="'application/x-tar,application/x-gzip'"
|
||||
ng-model="formValues.BackupFile"
|
||||
auto-focus
|
||||
data-cy="init-selectBackupFileButton"
|
||||
>Select file</button
|
||||
>
|
||||
<span class="space-left vertical-center">
|
||||
{{ formValues.BackupFile.name }}
|
||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- password-input -->
|
||||
<div class="form-group">
|
||||
<label for="password" class="col-sm-3 control-label text-left">
|
||||
Password
|
||||
<portainer-tooltip
|
||||
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !password-input -->
|
||||
</div>
|
||||
<!-- !select-file-input -->
|
||||
<!-- password-input -->
|
||||
<div class="form-group">
|
||||
<label for="password" class="col-sm-3 control-label text-left">
|
||||
Password
|
||||
<portainer-tooltip
|
||||
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
|
||||
<div class="limited-be-content" ng-if="formValues.restoreFormType === RESTORE_FORM_TYPES.S3">
|
||||
<!-- Access key id -->
|
||||
<div class="form-group">
|
||||
<label for="access_key_id" class="col-sm-3 control-label text-left">Access key ID</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="access_key_id" name="access_key_id" ng-model="formValues.AccessKeyId" required data-cy="init-accessKeyIdInput" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Access key id -->
|
||||
<!-- Secret access key -->
|
||||
<div class="form-group">
|
||||
<label for="secret_access_key" class="col-sm-3 control-label text-left">Secret access key</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="secret_access_key"
|
||||
name="secret_access_key"
|
||||
ng-model="formValues.SecretAccessKey"
|
||||
required
|
||||
data-cy="init-secretAccessKeyInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Secret access key -->
|
||||
<!-- Region -->
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-region" class="col-sm-3 control-label text-left">Region</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="default region is us-east-1 if left empty"
|
||||
id="backup-s3-region"
|
||||
name="backup-s3-region"
|
||||
ng-model="formValues.Region"
|
||||
data-cy="init-s3RegionInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Region -->
|
||||
<!-- Bucket name -->
|
||||
<div class="form-group">
|
||||
<label for="bucket_name" class="col-sm-3 control-label text-left">Bucket name</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="bucket_name" name="bucket_name" ng-model="formValues.BucketName" required data-cy="init-bucketNameInput" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Bucket name -->
|
||||
<!-- S3 Compatible Host -->
|
||||
<div class="form-group">
|
||||
<label for="s3-compatible-host" class="col-sm-3 control-label text-left">
|
||||
S3 Compatible Host
|
||||
<portainer-tooltip message="'Hostname of a S3 service'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="s3-compatible-host"
|
||||
name="s3-compatible-host"
|
||||
ng-model="formValues.S3CompatibleHost"
|
||||
placeholder="leave empty for AWS S3"
|
||||
data-cy="init-s3CompatibleHostInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !S3 Compatible Host -->
|
||||
<!-- Filename -->
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-filename" class="col-sm-3 control-label text-left">Filename</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="backup-s3-filename"
|
||||
name="backup-s3-filename"
|
||||
ng-model="formValues.Filename"
|
||||
required
|
||||
data-cy="init-backupFilenameInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Filename -->
|
||||
<!-- password-input -->
|
||||
<div class="form-group">
|
||||
<label for="password" class="col-sm-3 control-label text-left">
|
||||
Password
|
||||
<portainer-tooltip
|
||||
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !password-input -->
|
||||
</div>
|
||||
<!-- !password-input -->
|
||||
<!-- note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
|
||||
@@ -17,12 +17,14 @@ angular.module('portainer.app').controller('InitAdminController', [
|
||||
$scope.uploadBackup = uploadBackup;
|
||||
|
||||
$scope.logo = StateManager.getState().application.logo;
|
||||
$scope.RESTORE_FORM_TYPES = { S3: 's3', FILE: 'file' };
|
||||
|
||||
$scope.formValues = {
|
||||
Username: 'admin',
|
||||
Password: '',
|
||||
ConfirmPassword: '',
|
||||
enableTelemetry: process.env.NODE_ENV === 'production',
|
||||
restoreFormType: $scope.RESTORE_FORM_TYPES.FILE,
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
|
||||
@@ -302,10 +302,11 @@ angular
|
||||
$scope.state.selectedTemplate = template;
|
||||
|
||||
try {
|
||||
$scope.state.templateContent = await this.CustomTemplateService.customTemplateFile(templateId, template.GitConfig !== null);
|
||||
const isGit = template.GitConfig !== null;
|
||||
$scope.state.templateContent = await this.CustomTemplateService.customTemplateFile(templateId, isGit);
|
||||
onChangeFileContent($scope.state.templateContent);
|
||||
|
||||
$scope.state.isEditorReadOnly = false;
|
||||
$scope.state.isEditorReadOnly = isGit;
|
||||
} catch (err) {
|
||||
$scope.state.templateLoadFailed = true;
|
||||
throw err;
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
is-force-pull-visible="true"
|
||||
base-webhook-url="{{ state.baseWebhookUrl }}"
|
||||
webhook-id="{{ state.webhookId }}"
|
||||
webhooks-docs="https://docs.portainer.io/user/docker/stacks/webhooks"
|
||||
webhooks-docs="/user/docker/stacks/webhooks"
|
||||
></git-form>
|
||||
|
||||
<div ng-show="state.Method === 'template'">
|
||||
|
||||
@@ -38,16 +38,7 @@ export function react2angular<T, U extends PropNames<T>[]>(
|
||||
Component: React.ComponentType<T & JSX.IntrinsicAttributes>,
|
||||
propNames: U & ([PropNames<T>] extends [U[number]] ? unknown : PropNames<T>)
|
||||
): IComponentOptions & { name: string } {
|
||||
const bindings = Object.fromEntries(
|
||||
propNames.map((key) => {
|
||||
// use two way binding for errors, to avoid shifting the layout from errors going between undefined <-> some value when using inputs.
|
||||
// See https://portainer.atlassian.net/browse/EE-6570 for more context
|
||||
if (key === 'errors') {
|
||||
return [key, '='];
|
||||
}
|
||||
return [key, '<'];
|
||||
})
|
||||
);
|
||||
const bindings = Object.fromEntries(propNames.map((key) => [key, '<']));
|
||||
|
||||
return {
|
||||
bindings,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import 'vitest-dom/extend-expect';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
|
||||
import { PropsWithChildren, ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
function Provider({ children }: PropsWithChildren<unknown>) {
|
||||
return <UIRouter plugins={[pushStateLocationPlugin]}>{children}</UIRouter>;
|
||||
}
|
||||
|
||||
function customRender(ui: ReactElement, options?: RenderOptions) {
|
||||
return render(ui, { wrapper: Provider, ...options });
|
||||
}
|
||||
|
||||
// re-export everything
|
||||
export * from '@testing-library/react';
|
||||
|
||||
// override render method
|
||||
export { customRender as render };
|
||||
|
||||
export function renderWithQueryClient(ui: React.ReactElement) {
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const { rerender, ...result } = customRender(
|
||||
<QueryClientProvider client={testQueryClient}>{ui}</QueryClientProvider>
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
rerender: (rerenderUi: React.ReactElement) =>
|
||||
rerender(
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{rerenderUi}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ interface FormFieldProps<TValue> {
|
||||
|
||||
type WithFormFieldProps<TProps, TValue> = TProps & FormFieldProps<TValue>;
|
||||
|
||||
type ValidationResult<T> = FormikErrors<T> | undefined;
|
||||
|
||||
/**
|
||||
* This utility function is used for wrapping React components with form validation.
|
||||
* When used inside an Angular form, it sets the form to invalid if the component values are invalid.
|
||||
@@ -123,16 +125,9 @@ function createFormValidatorController<TFormModel, TData = never>(
|
||||
this.form?.$setValidity('form', true, this.form);
|
||||
|
||||
const schema = schemaBuilder(this.validationData);
|
||||
this.errors = undefined;
|
||||
const errors = await (isPrimitive
|
||||
? validateForm<{ value: TFormModel }>(
|
||||
() => object({ value: schema }),
|
||||
{ value }
|
||||
).then((r) => r?.value)
|
||||
: validateForm<TFormModel>(() => schema, value));
|
||||
this.errors = await validate<TFormModel>(schema, value, isPrimitive);
|
||||
|
||||
if (errors && Object.keys(errors).length > 0) {
|
||||
this.errors = errors as FormikErrors<TFormModel> | undefined;
|
||||
if (this.errors && Object.keys(this.errors).length > 0) {
|
||||
this.form?.$setValidity('form', false, this.form);
|
||||
}
|
||||
});
|
||||
@@ -152,3 +147,18 @@ function createFormValidatorController<TFormModel, TData = never>(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function validate<TFormModel>(
|
||||
schema: SchemaOf<TFormModel>,
|
||||
value: TFormModel,
|
||||
isPrimitive: boolean
|
||||
): Promise<ValidationResult<TFormModel>> {
|
||||
if (isPrimitive) {
|
||||
const result = await validateForm<{ value: TFormModel }>(
|
||||
() => object({ value: schema }),
|
||||
{ value }
|
||||
);
|
||||
return result?.value as ValidationResult<TFormModel>;
|
||||
}
|
||||
return validateForm<TFormModel>(() => schema, value);
|
||||
}
|
||||
|
||||
19
app/react-tools/yup-schemas.ts
Normal file
19
app/react-tools/yup-schemas.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NumberSchema, number } from 'yup';
|
||||
|
||||
/**
|
||||
* Returns a Yup schema for a number that can also be NaN.
|
||||
*
|
||||
* This function is a workaround for a known issue in Yup where it throws type errors
|
||||
* when the number input is empty, having a value NaN. Yup doesn't like NaN values.
|
||||
* More details can be found in these GitHub issues:
|
||||
* https://github.com/jquense/yup/issues/1330
|
||||
* https://github.com/jquense/yup/issues/211
|
||||
*
|
||||
* @param errorMessage The custom error message to display when the value is required.
|
||||
* @returns A Yup number schema with a custom type error message.
|
||||
*/
|
||||
export function nanNumberSchema(
|
||||
errorMessage = 'Value is required'
|
||||
): NumberSchema {
|
||||
return number().typeError(errorMessage);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user