Compare commits
23 Commits
fix/EE-672
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8f78cdf5 | ||
|
|
fe6ed55cab | ||
|
|
edea9e3481 | ||
|
|
c08b5af85a | ||
|
|
ed861044a7 | ||
|
|
a83321ebe6 | ||
|
|
513cd9c9b3 | ||
|
|
dc94bf141e | ||
|
|
24471a9ae1 | ||
|
|
aca6d33548 | ||
|
|
ca77b85c65 | ||
|
|
1fd4291630 | ||
|
|
08dd7f6d2a | ||
|
|
ce4b0e759c | ||
|
|
538e7a823b | ||
|
|
956e8d3c59 | ||
|
|
1c5458f0d4 | ||
|
|
f6085ffad7 | ||
|
|
490bda2eaf | ||
|
|
d601d8eb7b | ||
|
|
b0564b9238 | ||
|
|
8922585a70 | ||
|
|
d7cf2284dc |
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
html {
|
||||
font-size: 16px;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html[theme='dark'],
|
||||
|
||||
@@ -62,7 +62,7 @@ angular
|
||||
|
||||
const stacksNew = {
|
||||
name: 'edge.stacks.new',
|
||||
url: '/new?templateId',
|
||||
url: '/new?templateId&templateType',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createEdgeStackView',
|
||||
|
||||
@@ -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') {
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
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,12 @@ export default class CreateEdgeStackViewController {
|
||||
createStack() {
|
||||
return this.$async(async () => {
|
||||
const name = this.formValues.Name;
|
||||
|
||||
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 +239,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 +291,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 +315,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 +330,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,
|
||||
@@ -354,3 +389,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="'Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use the application name.'"
|
||||
stacks="ctrl.stacks"
|
||||
input-class-name="'col-lg-10 col-sm-9'"
|
||||
></kube-stack-name>
|
||||
@@ -226,9 +227,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="'Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use the application name.'"
|
||||
stacks="ctrl.stacks"
|
||||
input-class-name="'col-lg-10 col-sm-9'"
|
||||
></kube-stack-name>
|
||||
@@ -403,7 +405,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;
|
||||
@@ -157,6 +159,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,
|
||||
@@ -255,7 +258,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 {
|
||||
@@ -290,7 +293,7 @@ class KubernetesCreateApplicationController {
|
||||
onAutoScaleChange(values) {
|
||||
return this.$async(async () => {
|
||||
// when enabling the auto scaler, set the default values
|
||||
if (!this.oldFormValues.AutoScaler.isUsed && values.isUsed) {
|
||||
if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
|
||||
this.formValues.AutoScaler = {
|
||||
isUsed: values.isUsed,
|
||||
minReplicas: 1,
|
||||
@@ -643,6 +646,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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,11 +86,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-fit mb-4">
|
||||
<stack-name-label-insight></stack-name-label-insight>
|
||||
</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"
|
||||
stacks="ctrl.stacks"
|
||||
></kube-stack-name>
|
||||
<!-- !namespace -->
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -48,6 +48,7 @@ export const ngModule = angular
|
||||
'disabledTypes',
|
||||
'fixedCategories',
|
||||
'storageKey',
|
||||
'templateLinkParams',
|
||||
])
|
||||
)
|
||||
.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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getCurrentUser } from '../users/queries/useLoadCurrentUser';
|
||||
import * as userHelpers from '../users/user.helpers';
|
||||
import { clear as clearSessionStorage } from './session-storage';
|
||||
|
||||
const DEFAULT_USER = 'admin';
|
||||
@@ -25,6 +26,9 @@ angular.module('portainer.app').factory('Authentication', [
|
||||
service.isAuthenticated = isAuthenticated;
|
||||
service.getUserDetails = getUserDetails;
|
||||
service.isAdmin = isAdmin;
|
||||
service.isEdgeAdmin = isEdgeAdmin;
|
||||
service.isPureAdmin = isPureAdmin;
|
||||
service.hasAuthorizations = hasAuthorizations;
|
||||
|
||||
async function initAsync() {
|
||||
try {
|
||||
@@ -120,8 +124,36 @@ angular.module('portainer.app').factory('Authentication', [
|
||||
return login(DEFAULT_USER, DEFAULT_PASSWORD);
|
||||
}
|
||||
|
||||
// To avoid creating divergence between CE and EE
|
||||
// isAdmin checks if the user is a portainer admin or edge admin
|
||||
function isEdgeAdmin() {
|
||||
const environment = EndpointProvider.currentEndpoint();
|
||||
return userHelpers.isEdgeAdmin({ Role: user.role }, environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use Authentication.isAdmin instead
|
||||
*/
|
||||
function isAdmin() {
|
||||
return !!user && user.role === 1;
|
||||
return isEdgeAdmin();
|
||||
}
|
||||
|
||||
// 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 (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if (!user.endpointAuthorizations || !user.endpointAuthorizations[endpointId]) {
|
||||
return false;
|
||||
}
|
||||
const userEndpointAuthorizations = user.endpointAuthorizations[endpointId];
|
||||
return authorizations.some((authorization) => userEndpointAuthorizations[authorization]);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -109,6 +111,7 @@ function createFormValidatorController<TFormModel, TData = never>(
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.runValidation = this.runValidation.bind(this);
|
||||
this.validate = this.validate.bind(this);
|
||||
}
|
||||
|
||||
async handleChange(newValues: TFormModel) {
|
||||
@@ -123,21 +126,31 @@ 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 this.validate(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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
schema: SchemaOf<TFormModel>,
|
||||
value: TFormModel,
|
||||
isPrimitive: boolean
|
||||
): Promise<ValidationResult<TFormModel>> {
|
||||
return this.$async(async () => {
|
||||
if (isPrimitive) {
|
||||
const result = await validateForm<{ value: TFormModel }>(
|
||||
() => object({ value: schema }),
|
||||
{ value }
|
||||
);
|
||||
return result?.value as ValidationResult<TFormModel>;
|
||||
}
|
||||
return validateForm<TFormModel>(() => schema, value);
|
||||
});
|
||||
}
|
||||
|
||||
async $onChanges(changes: {
|
||||
values?: { currentValue: TFormModel };
|
||||
validationData?: { currentValue: TData };
|
||||
|
||||
@@ -29,15 +29,15 @@ test('submit button should be disabled when name or image is missing', async ()
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const nameInput = getByLabelText(/name/i);
|
||||
userEvent.type(nameInput, 'name');
|
||||
await userEvent.type(nameInput, 'name');
|
||||
|
||||
const imageInput = getByLabelText(/image/i);
|
||||
userEvent.type(imageInput, 'image');
|
||||
await userEvent.type(imageInput, 'image');
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
|
||||
|
||||
expect(nameInput).toHaveValue('name');
|
||||
userEvent.clear(nameInput);
|
||||
await userEvent.clear(nameInput);
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Plus } from 'lucide-react';
|
||||
|
||||
import { ContainerInstanceFormValues } from '@/react/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useCreateInstanceMutation } from './useCreateInstanceMutation';
|
||||
|
||||
export function CreateContainerInstanceForm() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { isAdmin } = useUser();
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
const { providers, subscriptions, resourceGroups, isLoading } =
|
||||
useLoadFormState(environmentId);
|
||||
@@ -49,7 +49,7 @@ export function CreateContainerInstanceForm() {
|
||||
return (
|
||||
<Formik<ContainerInstanceFormValues>
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(isAdmin)}
|
||||
validationSchema={() => validationSchema(isPureAdmin)}
|
||||
onSubmit={onSubmit}
|
||||
validateOnMount
|
||||
validateOnChange
|
||||
|
||||
@@ -37,7 +37,7 @@ export function useFormState(
|
||||
resourceGroups: Record<string, ResourceGroup[]> = {},
|
||||
providers: Record<string, ProviderViewModel> = {}
|
||||
) {
|
||||
const { isAdmin, user } = useCurrentUser();
|
||||
const { user, isPureAdmin } = useCurrentUser();
|
||||
|
||||
const subscriptionOptions = subscriptions.map((s) => ({
|
||||
value: s.subscriptionId,
|
||||
@@ -67,7 +67,7 @@ export function useFormState(
|
||||
cpu: 1,
|
||||
ports: [{ container: 80, host: 80, protocol: 'TCP' }],
|
||||
allocatePublicIP: true,
|
||||
accessControl: parseAccessControlFormData(isAdmin, user.Id),
|
||||
accessControl: parseAccessControlFormData(isPureAdmin, user.Id),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
26
app/react/components/Badge/Badge.test.tsx
Normal file
26
app/react/components/Badge/Badge.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
|
||||
import { Badge } from './Badge';
|
||||
|
||||
test('should render a Badge component with default type', () => {
|
||||
const { getByText } = render(<Badge>Default Badge</Badge>);
|
||||
const badgeElement = getByText('Default Badge');
|
||||
expect(badgeElement).toBeInTheDocument();
|
||||
expect(badgeElement).toHaveClass('text-blue-9 bg-blue-2');
|
||||
});
|
||||
|
||||
test('should render a Badge component with custom type', () => {
|
||||
const { getByText } = render(<Badge type="success">Success Badge</Badge>);
|
||||
const badgeElement = getByText('Success Badge');
|
||||
expect(badgeElement).toBeInTheDocument();
|
||||
expect(badgeElement).toHaveClass('text-success-9 bg-success-2');
|
||||
});
|
||||
|
||||
test('should render a Badge component with custom className', () => {
|
||||
const { getByText } = render(
|
||||
<Badge className="custom-class">Custom Badge</Badge>
|
||||
);
|
||||
const badgeElement = getByText('Custom Badge');
|
||||
expect(badgeElement).toBeInTheDocument();
|
||||
expect(badgeElement).toHaveClass('custom-class');
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const edgeStackTemplate: BoxSelectorOption<'template'> = {
|
||||
icon: FileText,
|
||||
iconType: 'badge',
|
||||
label: 'Template',
|
||||
description: 'Use an Edge stack template',
|
||||
description: 'Use an Edge stack app or custom template',
|
||||
value: 'template',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { FormikErrors } from 'formik';
|
||||
import { ComponentProps } from 'react';
|
||||
import { HttpResponse } from 'msw';
|
||||
|
||||
import { renderWithQueryClient, fireEvent } from '@/react-tools/test-utils';
|
||||
import { http, server } from '@/setup-tests/server';
|
||||
|
||||
import { ImageConfigFieldset } from './ImageConfigFieldset';
|
||||
import { Values } from './types';
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: vi.fn(() => ({
|
||||
params: { endpointId: 1 },
|
||||
})),
|
||||
}));
|
||||
|
||||
it('should render SimpleForm when useRegistry is true', () => {
|
||||
const { getByText } = render({ values: { useRegistry: true } });
|
||||
|
||||
expect(getByText('Advanced mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render AdvancedForm when useRegistry is false', () => {
|
||||
const { getByText } = render({ values: { useRegistry: false } });
|
||||
|
||||
expect(getByText('Simple mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call setFieldValue with useRegistry set to false when "Advanced mode" button is clicked', () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const { getByText } = render({
|
||||
values: { useRegistry: true },
|
||||
setFieldValue,
|
||||
});
|
||||
|
||||
fireEvent.click(getByText('Advanced mode'));
|
||||
|
||||
expect(setFieldValue).toHaveBeenCalledWith('useRegistry', false);
|
||||
});
|
||||
|
||||
it('should call setFieldValue with useRegistry set to true when "Simple mode" button is clicked', () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const { getByText } = render({
|
||||
values: { useRegistry: false },
|
||||
setFieldValue,
|
||||
});
|
||||
|
||||
fireEvent.click(getByText('Simple mode'));
|
||||
|
||||
expect(setFieldValue).toHaveBeenCalledWith('useRegistry', true);
|
||||
});
|
||||
|
||||
function render({
|
||||
values = {
|
||||
useRegistry: true,
|
||||
registryId: 123,
|
||||
image: '',
|
||||
},
|
||||
errors = {},
|
||||
setFieldValue = vi.fn(),
|
||||
onChangeImage = vi.fn(),
|
||||
onRateLimit = vi.fn(),
|
||||
}: {
|
||||
values?: Partial<Values>;
|
||||
errors?: FormikErrors<Values>;
|
||||
setFieldValue?: ComponentProps<typeof ImageConfigFieldset>['setFieldValue'];
|
||||
onChangeImage?: ComponentProps<typeof ImageConfigFieldset>['onChangeImage'];
|
||||
onRateLimit?: ComponentProps<typeof ImageConfigFieldset>['onRateLimit'];
|
||||
} = {}) {
|
||||
server.use(
|
||||
http.get('/api/registries/:id', () => HttpResponse.json({})),
|
||||
http.get('/api/endpoints/:id', () => HttpResponse.json({}))
|
||||
);
|
||||
|
||||
return renderWithQueryClient(
|
||||
<ImageConfigFieldset
|
||||
values={{
|
||||
useRegistry: true,
|
||||
registryId: 123,
|
||||
image: '',
|
||||
...values,
|
||||
}}
|
||||
errors={errors}
|
||||
setFieldValue={setFieldValue}
|
||||
onChangeImage={onChangeImage}
|
||||
onRateLimit={onRateLimit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ function RateLimitsInner({
|
||||
environment: Environment;
|
||||
}) {
|
||||
const pullRateLimits = useRateLimits(registryId, environment, onRateLimit);
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
if (!pullRateLimits) {
|
||||
return null;
|
||||
@@ -88,7 +88,7 @@ function RateLimitsInner({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isAdmin ? (
|
||||
{isPureAdmin ? (
|
||||
<>
|
||||
You are currently using an anonymous account to pull images
|
||||
from DockerHub and will be limited to 100 pulls every 6
|
||||
|
||||
@@ -42,7 +42,7 @@ test('should call onSelect when clicked with id', async () => {
|
||||
const { findByText } = renderComponent(options, options[1].id, onSelect);
|
||||
|
||||
const heading = await findByText(options[0].label);
|
||||
userEvent.click(heading);
|
||||
await userEvent.click(heading);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(options[0].id);
|
||||
});
|
||||
|
||||
@@ -2,19 +2,23 @@ import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
|
||||
const Context = createContext<null | boolean>(null);
|
||||
Context.displayName = 'WidgetContext';
|
||||
|
||||
export function useWidgetContext() {
|
||||
const context = useContext(Context);
|
||||
|
||||
if (context == null) {
|
||||
throw new Error('Should be inside a Widget component');
|
||||
}
|
||||
}
|
||||
|
||||
export function Widget({ children }: PropsWithChildren<unknown>) {
|
||||
export function Widget({
|
||||
children,
|
||||
id,
|
||||
}: PropsWithChildren<{
|
||||
id?: string;
|
||||
}>) {
|
||||
return (
|
||||
<Context.Provider value>
|
||||
<div className="widget">{children}</div>
|
||||
<div id={id} className="widget">
|
||||
{children}
|
||||
</div>
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||
emptyContentLabel?: string;
|
||||
title?: string;
|
||||
titleIcon?: IconProps['icon'];
|
||||
titleId?: string;
|
||||
initialTableState?: Partial<TableState>;
|
||||
isLoading?: boolean;
|
||||
description?: ReactNode;
|
||||
@@ -78,6 +79,7 @@ export function Datatable<D extends DefaultType>({
|
||||
getRowId = defaultGetRowId,
|
||||
isRowSelectable = () => true,
|
||||
title,
|
||||
titleId,
|
||||
titleIcon,
|
||||
emptyContentLabel,
|
||||
initialTableState = {},
|
||||
@@ -172,6 +174,7 @@ export function Datatable<D extends DefaultType>({
|
||||
onSearchChange={handleSearchBarChange}
|
||||
searchValue={settings.search}
|
||||
title={title}
|
||||
titleId={titleId}
|
||||
titleIcon={titleIcon}
|
||||
description={description}
|
||||
renderTableActions={() => renderTableActions(selectedItems)}
|
||||
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
renderTableSettings?(): ReactNode;
|
||||
renderTableActions?(): ReactNode;
|
||||
description?: ReactNode;
|
||||
titleId?: string;
|
||||
};
|
||||
|
||||
export function DatatableHeader({
|
||||
@@ -23,6 +24,7 @@ export function DatatableHeader({
|
||||
title,
|
||||
titleIcon,
|
||||
description,
|
||||
titleId,
|
||||
}: Props) {
|
||||
if (!title) {
|
||||
return null;
|
||||
@@ -37,7 +39,12 @@ export function DatatableHeader({
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Title label={title} icon={titleIcon} description={description}>
|
||||
<Table.Title
|
||||
id={titleId}
|
||||
label={title}
|
||||
icon={titleIcon}
|
||||
description={description}
|
||||
>
|
||||
{searchBar}
|
||||
{tableActions}
|
||||
{tableTitleSettings}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Props {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function TableTitle({
|
||||
@@ -16,10 +17,11 @@ export function TableTitle({
|
||||
children,
|
||||
description,
|
||||
className,
|
||||
id,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<>
|
||||
<div className={clsx('toolBar flex-col', className)}>
|
||||
<div className={clsx('toolBar flex-col', className)} id={id}>
|
||||
<div className="flex w-full items-center gap-1 p-0">
|
||||
<div className="toolBarTitle">
|
||||
{icon && (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Values } from './BaseForm';
|
||||
|
||||
export function toViewModel(
|
||||
config: ContainerResponse,
|
||||
isAdmin: boolean,
|
||||
isPureAdmin: boolean,
|
||||
currentUserId: UserId,
|
||||
nodeName: string,
|
||||
image: Values['image'],
|
||||
@@ -18,7 +18,7 @@ export function toViewModel(
|
||||
): Values {
|
||||
// accessControl shouldn't be copied to new container
|
||||
|
||||
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
|
||||
const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId);
|
||||
|
||||
if (config.Portainer?.ResourceControl?.Public) {
|
||||
accessControl.ownership = ResourceControlOwnership.PUBLIC;
|
||||
@@ -38,11 +38,11 @@ export function toViewModel(
|
||||
}
|
||||
|
||||
export function getDefaultViewModel(
|
||||
isAdmin: boolean,
|
||||
isPureAdmin: boolean,
|
||||
currentUserId: UserId,
|
||||
nodeName: string
|
||||
): Values {
|
||||
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
|
||||
const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId);
|
||||
|
||||
return {
|
||||
nodeName,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCurrentUser, useIsEnvironmentAdmin } from '@/react/hooks/useUser';
|
||||
import { useIsEdgeAdmin, useIsEnvironmentAdmin } from '@/react/hooks/useUser';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||
@@ -48,7 +48,7 @@ function CreateForm() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const router = useRouter();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const isEnvironmentAdmin = useIsEnvironmentAdmin();
|
||||
const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
|
||||
|
||||
@@ -67,7 +67,7 @@ function CreateForm() {
|
||||
const envQuery = useCurrentEnvironment();
|
||||
|
||||
const validationSchema = useValidation({
|
||||
isAdmin,
|
||||
isAdmin: isAdminQuery.isAdmin,
|
||||
maxCpu,
|
||||
maxMemory,
|
||||
isDuplicating: initialValuesQuery?.isDuplicating,
|
||||
|
||||
@@ -102,7 +102,7 @@ export function InnerForm({
|
||||
}
|
||||
errors={errors.volumes}
|
||||
allowBindMounts={
|
||||
isEnvironmentAdmin ||
|
||||
isEnvironmentAdmin.authorized ||
|
||||
environment.SecuritySettings
|
||||
.allowBindMountsForRegularUsers
|
||||
}
|
||||
@@ -166,18 +166,18 @@ export function InnerForm({
|
||||
setFieldValue(`resources.${field}`, value)
|
||||
}
|
||||
allowPrivilegedMode={
|
||||
isEnvironmentAdmin ||
|
||||
isEnvironmentAdmin.authorized ||
|
||||
environment.SecuritySettings
|
||||
.allowPrivilegedModeForRegularUsers
|
||||
}
|
||||
isDevicesFieldVisible={
|
||||
isEnvironmentAdmin ||
|
||||
isEnvironmentAdmin.authorized ||
|
||||
environment.SecuritySettings
|
||||
.allowDeviceMappingForRegularUsers
|
||||
}
|
||||
isInitFieldVisible={apiVersion >= 1.37}
|
||||
isSysctlFieldVisible={
|
||||
isEnvironmentAdmin ||
|
||||
isEnvironmentAdmin.authorized ||
|
||||
environment.SecuritySettings
|
||||
.allowSysctlSettingForRegularUsers
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ export function useInitialValues(submitting: boolean) {
|
||||
params: { nodeName, from },
|
||||
} = useCurrentStateAndParams();
|
||||
const environmentId = useEnvironmentId();
|
||||
const { isAdmin, user } = useCurrentUser();
|
||||
const { user, isPureAdmin } = useCurrentUser();
|
||||
|
||||
const networksQuery = useNetworksForSelector();
|
||||
|
||||
const fromContainerQuery = useContainer(environmentId, from, {
|
||||
@@ -85,7 +86,7 @@ export function useInitialValues(submitting: boolean) {
|
||||
|
||||
if (!from) {
|
||||
return {
|
||||
initialValues: defaultValues(isAdmin, user.Id, nodeName),
|
||||
initialValues: defaultValues(isPureAdmin, user.Id, nodeName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ export function useInitialValues(submitting: boolean) {
|
||||
env: envVarsTabUtils.toViewModel(fromContainer),
|
||||
...baseFormUtils.toViewModel(
|
||||
fromContainer,
|
||||
isAdmin,
|
||||
isPureAdmin,
|
||||
user.Id,
|
||||
nodeName,
|
||||
imageConfig,
|
||||
@@ -148,7 +149,7 @@ export function useInitialValues(submitting: boolean) {
|
||||
}
|
||||
|
||||
function defaultValues(
|
||||
isAdmin: boolean,
|
||||
isPureAdmin: boolean,
|
||||
currentUserId: UserId,
|
||||
nodeName: string
|
||||
): Values {
|
||||
@@ -161,6 +162,6 @@ function defaultValues(
|
||||
resources: resourcesTabUtils.getDefaultViewModel(),
|
||||
capabilities: capabilitiesTabUtils.getDefaultViewModel(),
|
||||
env: envVarsTabUtils.getDefaultViewModel(),
|
||||
...baseFormUtils.getDefaultViewModel(isAdmin, currentUserId, nodeName),
|
||||
...baseFormUtils.getDefaultViewModel(isPureAdmin, currentUserId, nodeName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
@@ -50,7 +50,7 @@ test('Non system networks should have a delete button', async () => {
|
||||
async function renderComponent(isAdmin: boolean, network: DockerNetwork) {
|
||||
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
|
||||
|
||||
const queries = render(
|
||||
const queries = renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<NetworkDetailsTable
|
||||
network={network}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Layers } from 'lucide-react';
|
||||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { useAuthorizations, useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useAuthorizations, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
@@ -34,7 +34,7 @@ export function StacksDatatable({
|
||||
}) {
|
||||
const tableState = useTableState(settingsStore, tableKey);
|
||||
useRepeater(tableState.autoRefreshRate, onReload);
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const canManageStacks = useAuthorizations([
|
||||
'PortainerStackCreate',
|
||||
'PortainerStackDelete',
|
||||
@@ -58,7 +58,7 @@ export function StacksDatatable({
|
||||
columns={columns}
|
||||
dataset={dataset}
|
||||
isRowSelectable={({ original: item }) =>
|
||||
allowSelection(item, isAdmin, canManageStacks)
|
||||
allowSelection(item, isAdminQuery.isAdmin, canManageStacks.authorized)
|
||||
}
|
||||
getRowId={(item) => item.Id.toString()}
|
||||
initialTableState={{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CellContext, Column } from '@tanstack/react-table';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||
import { StackStatus } from '@/react/common/stacks/types';
|
||||
import {
|
||||
@@ -67,7 +67,7 @@ function NameCell({
|
||||
}
|
||||
|
||||
function NameLink({ item }: { item: DecoratedStack }) {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
const name = item.Name;
|
||||
|
||||
@@ -87,7 +87,7 @@ function NameLink({ item }: { item: DecoratedStack }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin && isOrphanedStack(item)) {
|
||||
if (!isAdminQuery.isAdmin && isOrphanedStack(item)) {
|
||||
return <>{name}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { useIsPureAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { ModalType, openModal } from '@@/modals';
|
||||
@@ -28,6 +29,7 @@ export function TableActions({
|
||||
}: {
|
||||
selectedRows: WaitingRoomEnvironment[];
|
||||
}) {
|
||||
const isPureAdmin = useIsPureAdmin();
|
||||
const associateMutation = useAssociateDeviceMutation();
|
||||
const removeMutation = useDeleteEnvironmentsMutation();
|
||||
const licenseOverused = useLicenseOverused(selectedRows.length);
|
||||
@@ -58,7 +60,9 @@ export function TableActions({
|
||||
<span>
|
||||
<Button
|
||||
onClick={() => handleAssociateAndAssign(selectedRows)}
|
||||
disabled={selectedRows.length === 0 || licenseOverused}
|
||||
disabled={
|
||||
selectedRows.length === 0 || licenseOverused || !isPureAdmin
|
||||
}
|
||||
color="secondary"
|
||||
icon={CheckCircle}
|
||||
>
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { SetStateAction, useEffect, useState } from 'react';
|
||||
import sanitize from 'sanitize-html';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import {
|
||||
CustomTemplatesVariablesField,
|
||||
VariablesFieldValue,
|
||||
getVariablesFieldDefaultValues,
|
||||
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
|
||||
export interface Values {
|
||||
template: CustomTemplate | undefined;
|
||||
variables: VariablesFieldValue;
|
||||
}
|
||||
|
||||
export function TemplateFieldset({
|
||||
values: initialValues,
|
||||
setValues: setInitialValues,
|
||||
errors,
|
||||
}: {
|
||||
errors?: FormikErrors<Values>;
|
||||
values: Values;
|
||||
setValues: (values: SetStateAction<Values>) => void;
|
||||
}) {
|
||||
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues.template?.Id !== values.template?.Id) {
|
||||
setControlledValues(initialValues);
|
||||
}
|
||||
}, [initialValues, values.template?.Id]);
|
||||
|
||||
const templatesQuery = useCustomTemplates({
|
||||
params: {
|
||||
edge: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateSelector
|
||||
error={errors?.template}
|
||||
value={values.template?.Id}
|
||||
onChange={(value) => {
|
||||
setValues((values) => {
|
||||
const template = templatesQuery.data?.find(
|
||||
(template) => template.Id === value
|
||||
);
|
||||
return {
|
||||
...values,
|
||||
template,
|
||||
variables: getVariablesFieldDefaultValues(
|
||||
template?.Variables || []
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{values.template && (
|
||||
<>
|
||||
{values.template.Note && (
|
||||
<div>
|
||||
<div className="col-sm-12 form-section-title"> Information </div>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<div
|
||||
className="template-note"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(values.template.Note),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomTemplatesVariablesField
|
||||
onChange={(value) => {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
variables: value,
|
||||
}));
|
||||
}}
|
||||
value={values.variables}
|
||||
definitions={values.template.Variables}
|
||||
errors={errors?.variables}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function setValues(values: SetStateAction<Values>) {
|
||||
setControlledValues(values);
|
||||
setInitialValues(values);
|
||||
}
|
||||
}
|
||||
|
||||
function TemplateSelector({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
value: CustomTemplate['Id'] | undefined;
|
||||
onChange: (value: CustomTemplate['Id'] | undefined) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
const templatesQuery = useCustomTemplates({
|
||||
params: {
|
||||
edge: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!templatesQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl label="Template" inputId="stack_template" errors={error}>
|
||||
<PortainerSelect
|
||||
placeholder="Select an Edge stack template"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={templatesQuery.data.map((template) => ({
|
||||
label: `${template.Title} - ${template.Description}`,
|
||||
value: template.Id,
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
function handleChange(value: CustomTemplate['Id']) {
|
||||
onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function getInitialTemplateValues() {
|
||||
return {
|
||||
template: null,
|
||||
variables: [],
|
||||
file: '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from '@/react-tools/test-utils';
|
||||
import {
|
||||
EnvVarType,
|
||||
TemplateViewModel,
|
||||
} from '@/react/portainer/templates/app-templates/view-model';
|
||||
|
||||
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
||||
|
||||
test('renders AppTemplateFieldset component', () => {
|
||||
const testedEnv = {
|
||||
name: 'VAR2',
|
||||
label: 'Variable 2',
|
||||
default: 'value2',
|
||||
value: 'value2',
|
||||
type: EnvVarType.Text,
|
||||
};
|
||||
|
||||
const env = [
|
||||
{
|
||||
name: 'VAR1',
|
||||
label: 'Variable 1',
|
||||
default: 'value1',
|
||||
value: 'value1',
|
||||
type: EnvVarType.Text,
|
||||
},
|
||||
testedEnv,
|
||||
];
|
||||
const template = {
|
||||
Note: 'This is a template note',
|
||||
Env: env,
|
||||
} as TemplateViewModel;
|
||||
|
||||
const values: Record<string, string> = {
|
||||
VAR1: 'value1',
|
||||
VAR2: 'value2',
|
||||
};
|
||||
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<AppTemplateFieldset
|
||||
template={template}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const templateNoteElement = screen.getByText('This is a template note');
|
||||
expect(templateNoteElement).toBeInTheDocument();
|
||||
|
||||
const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, {
|
||||
exact: false,
|
||||
});
|
||||
expect(envVarsFieldsetElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
|
||||
import { EnvVarsFieldset } from './EnvVarsFieldset';
|
||||
import { TemplateNote } from './TemplateNote';
|
||||
|
||||
export function AppTemplateFieldset({
|
||||
template,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
values: Record<string, string>;
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
errors?: FormikErrors<Record<string, string>>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TemplateNote note={template.Note} />
|
||||
<EnvVarsFieldset
|
||||
options={template.Env || []}
|
||||
value={values}
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
|
||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||
|
||||
import { Values } from './types';
|
||||
import { TemplateNote } from './TemplateNote';
|
||||
|
||||
export function CustomTemplateFieldset({
|
||||
errors,
|
||||
onChange,
|
||||
values,
|
||||
template,
|
||||
}: {
|
||||
values: Values['variables'];
|
||||
onChange: (values: Values['variables']) => void;
|
||||
errors: ArrayError<Values['variables']> | undefined;
|
||||
template: CustomTemplate;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TemplateNote note={template.Note} />
|
||||
|
||||
<CustomTemplatesVariablesField
|
||||
onChange={onChange}
|
||||
value={values}
|
||||
definitions={template.Variables}
|
||||
errors={errors}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { render, screen } from '@/react-tools/test-utils';
|
||||
|
||||
import {
|
||||
EnvVarsFieldset,
|
||||
getDefaultValues,
|
||||
envVarsFieldsetValidation,
|
||||
} from './EnvVarsFieldset';
|
||||
|
||||
test('renders EnvVarsFieldset component', () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ name: 'VAR1', label: 'Variable 1', preset: false },
|
||||
{ name: 'VAR2', label: 'Variable 2', preset: false },
|
||||
] as const;
|
||||
const value = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
||||
const errors = {};
|
||||
|
||||
render(
|
||||
<EnvVarsFieldset
|
||||
onChange={onChange}
|
||||
options={[...options]}
|
||||
value={value}
|
||||
errors={errors}
|
||||
/>
|
||||
);
|
||||
|
||||
options.forEach((option) => {
|
||||
const labelElement = screen.getByLabelText(option.label, { exact: false });
|
||||
expect(labelElement).toBeInTheDocument();
|
||||
|
||||
const inputElement = screen.getByDisplayValue(value[option.name]);
|
||||
expect(inputElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('calls onChange when input value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
||||
const value = { VAR1: 'Value 1' };
|
||||
const errors = {};
|
||||
|
||||
render(
|
||||
<EnvVarsFieldset
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
value={value}
|
||||
errors={errors}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputElement = screen.getByDisplayValue(value.VAR1);
|
||||
await user.clear(inputElement);
|
||||
expect(onChange).toHaveBeenCalledWith({ VAR1: '' });
|
||||
|
||||
const newValue = 'New Value';
|
||||
await user.type(inputElement, newValue);
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders error message when there are errors', () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
||||
const value = { VAR1: 'Value 1' };
|
||||
const errors = { VAR1: 'Required' };
|
||||
|
||||
render(
|
||||
<EnvVarsFieldset
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
value={value}
|
||||
errors={errors}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorElement = screen.getByText('Required');
|
||||
expect(errorElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('returns default values', () => {
|
||||
const definitions = [
|
||||
{
|
||||
name: 'VAR1',
|
||||
label: 'Variable 1',
|
||||
preset: false,
|
||||
default: 'Default Value 1',
|
||||
},
|
||||
{
|
||||
name: 'VAR2',
|
||||
label: 'Variable 2',
|
||||
preset: false,
|
||||
default: 'Default Value 2',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues = getDefaultValues(definitions);
|
||||
|
||||
expect(defaultValues).toEqual({
|
||||
VAR1: 'Default Value 1',
|
||||
VAR2: 'Default Value 2',
|
||||
});
|
||||
});
|
||||
|
||||
test('validates env vars fieldset', () => {
|
||||
const schema = envVarsFieldsetValidation();
|
||||
|
||||
const validData = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
||||
const invalidData = { VAR1: '', VAR2: 'Value 2' };
|
||||
|
||||
const validResult = schema.isValidSync(validData);
|
||||
const invalidResult = schema.isValidSync(invalidData);
|
||||
|
||||
expect(validResult).toBe(true);
|
||||
expect(invalidResult).toBe(false);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormikErrors } from 'formik';
|
||||
import { SchemaOf, array, string } from 'yup';
|
||||
|
||||
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
||||
|
||||
@@ -20,13 +21,13 @@ export function EnvVarsFieldset({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{options.map((env, index) => (
|
||||
{options.map((env) => (
|
||||
<Item
|
||||
key={env.name}
|
||||
option={env}
|
||||
value={value[env.name]}
|
||||
onChange={(value) => handleChange(env.name, value)}
|
||||
errors={errors?.[index]}
|
||||
errors={errors?.[env.name]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -48,11 +49,13 @@ function Item({
|
||||
onChange: (value: string) => void;
|
||||
errors?: FormikErrors<string>;
|
||||
}) {
|
||||
const inputId = `env_var_${option.name}`;
|
||||
return (
|
||||
<FormControl
|
||||
label={option.label || option.name}
|
||||
required={!option.preset}
|
||||
errors={errors}
|
||||
inputId={inputId}
|
||||
>
|
||||
{option.select ? (
|
||||
<Select
|
||||
@@ -63,14 +66,29 @@ function Item({
|
||||
value: o.value,
|
||||
}))}
|
||||
disabled={option.preset}
|
||||
id={inputId}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={option.preset}
|
||||
id={inputId}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultValues(definitions: Array<TemplateEnv>): Value {
|
||||
return Object.fromEntries(definitions.map((v) => [v.name, v.default || '']));
|
||||
}
|
||||
|
||||
export function envVarsFieldsetValidation(): SchemaOf<Value> {
|
||||
return (
|
||||
array()
|
||||
.transform((_, orig) => Object.values(orig))
|
||||
// casting to return the correct type - validation works as expected
|
||||
.of(string().required('Required')) as unknown as SchemaOf<Value>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SetStateAction, useEffect, useState } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
|
||||
import { getDefaultValues as getAppVariablesDefaultValues } from './EnvVarsFieldset';
|
||||
import { TemplateSelector } from './TemplateSelector';
|
||||
import { SelectedTemplateValue, Values } from './types';
|
||||
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
|
||||
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
||||
|
||||
export function TemplateFieldset({
|
||||
values: initialValues,
|
||||
setValues: setInitialValues,
|
||||
errors,
|
||||
}: {
|
||||
errors?: FormikErrors<Values>;
|
||||
values: Values;
|
||||
setValues: (values: SetStateAction<Values>) => void;
|
||||
}) {
|
||||
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialValues.type !== values.type ||
|
||||
initialValues.template?.Id !== values.template?.Id
|
||||
) {
|
||||
setControlledValues(initialValues);
|
||||
}
|
||||
}, [initialValues, values]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateSelector
|
||||
error={
|
||||
typeof errors?.template === 'string' ? errors?.template : undefined
|
||||
}
|
||||
value={values}
|
||||
onChange={handleChangeTemplate}
|
||||
/>
|
||||
{values.template && (
|
||||
<>
|
||||
{values.type === 'custom' && (
|
||||
<CustomTemplateFieldset
|
||||
template={values.template}
|
||||
values={values.variables}
|
||||
onChange={(variables) =>
|
||||
setValues((values) => ({ ...values, variables }))
|
||||
}
|
||||
errors={errors?.variables}
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.type === 'app' && (
|
||||
<AppTemplateFieldset
|
||||
template={values.template}
|
||||
values={values.envVars}
|
||||
onChange={(envVars) =>
|
||||
setValues((values) => ({ ...values, envVars }))
|
||||
}
|
||||
errors={errors?.envVars}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function setValues(values: SetStateAction<Values>) {
|
||||
setControlledValues(values);
|
||||
setInitialValues(values);
|
||||
}
|
||||
|
||||
function handleChangeTemplate(value?: SelectedTemplateValue) {
|
||||
setValues(() => {
|
||||
if (!value || !value.type || !value.template) {
|
||||
return {
|
||||
type: undefined,
|
||||
template: undefined,
|
||||
variables: [],
|
||||
envVars: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (value.type === 'app') {
|
||||
return {
|
||||
template: value.template,
|
||||
type: value.type,
|
||||
variables: [],
|
||||
envVars: getAppVariablesDefaultValues(value.template.Env || []),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
template: value.template,
|
||||
type: value.type,
|
||||
variables: getVariablesFieldDefaultValues(
|
||||
value.template.Variables || []
|
||||
),
|
||||
envVars: {},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getInitialTemplateValues(): Values {
|
||||
return {
|
||||
template: undefined,
|
||||
type: undefined,
|
||||
variables: [],
|
||||
file: '',
|
||||
envVars: {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { render, screen } from '@/react-tools/test-utils';
|
||||
|
||||
import { TemplateNote } from './TemplateNote';
|
||||
|
||||
vi.mock('sanitize-html', () => ({
|
||||
default: (note: string) => note, // Mock the sanitize-html library to return the input as is
|
||||
}));
|
||||
|
||||
test('renders template note', async () => {
|
||||
render(<TemplateNote note="Test note" />);
|
||||
|
||||
const templateNoteElement = screen.getByText(/Information/);
|
||||
expect(templateNoteElement).toBeInTheDocument();
|
||||
|
||||
const noteElement = screen.getByText(/Test note/);
|
||||
expect(noteElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render template note when note is undefined', async () => {
|
||||
render(<TemplateNote note={undefined} />);
|
||||
|
||||
const templateNoteElement = screen.queryByText(/Information/);
|
||||
expect(templateNoteElement).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import sanitize from 'sanitize-html';
|
||||
|
||||
export function TemplateNote({ note }: { note: string | undefined }) {
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="col-sm-12 form-section-title"> Information </div>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<div
|
||||
className="template-note"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(note),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { vi } from 'vitest';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { renderWithQueryClient, screen } from '@/react-tools/test-utils';
|
||||
import { AppTemplate } from '@/react/portainer/templates/app-templates/types';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { server } from '@/setup-tests/server';
|
||||
import selectEvent from '@/react/test-utils/react-select';
|
||||
|
||||
import { SelectedTemplateValue } from './types';
|
||||
import { TemplateSelector } from './TemplateSelector';
|
||||
|
||||
test('renders TemplateSelector component', async () => {
|
||||
render();
|
||||
|
||||
const templateSelectorElement = screen.getByLabelText('Template');
|
||||
expect(templateSelectorElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
test('selects an edge app template', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const selectedTemplate = {
|
||||
title: 'App Template 2',
|
||||
description: 'Description 2',
|
||||
id: 2,
|
||||
categories: ['edge'],
|
||||
};
|
||||
|
||||
const { select } = render({
|
||||
onChange,
|
||||
appTemplates: [
|
||||
{
|
||||
title: 'App Template 1',
|
||||
description: 'Description 1',
|
||||
id: 1,
|
||||
categories: ['edge'],
|
||||
},
|
||||
selectedTemplate,
|
||||
],
|
||||
});
|
||||
|
||||
await select('app', {
|
||||
Title: selectedTemplate.title,
|
||||
Description: selectedTemplate.description,
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
test('selects an edge custom template', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const selectedTemplate = {
|
||||
Title: 'Custom Template 2',
|
||||
Description: 'Description 2',
|
||||
Id: 2,
|
||||
};
|
||||
|
||||
const { select } = render({
|
||||
onChange,
|
||||
customTemplates: [
|
||||
{
|
||||
Title: 'Custom Template 1',
|
||||
Description: 'Description 1',
|
||||
Id: 1,
|
||||
},
|
||||
selectedTemplate,
|
||||
],
|
||||
});
|
||||
|
||||
await select('custom', selectedTemplate);
|
||||
});
|
||||
|
||||
test('renders with error', async () => {
|
||||
render({
|
||||
error: 'Invalid template',
|
||||
});
|
||||
|
||||
const templateSelectorElement = screen.getByLabelText('Template');
|
||||
expect(templateSelectorElement).toBeInTheDocument();
|
||||
|
||||
const errorElement = screen.getByText('Invalid template');
|
||||
expect(errorElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders TemplateSelector component with no custom templates available', async () => {
|
||||
render({
|
||||
customTemplates: [],
|
||||
});
|
||||
|
||||
const templateSelectorElement = screen.getByLabelText('Template');
|
||||
expect(templateSelectorElement).toBeInTheDocument();
|
||||
|
||||
await selectEvent.openMenu(templateSelectorElement);
|
||||
|
||||
const noCustomTemplatesElement = screen.getByText(
|
||||
'No edge custom templates available'
|
||||
);
|
||||
expect(noCustomTemplatesElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function render({
|
||||
onChange = vi.fn(),
|
||||
appTemplates = [],
|
||||
customTemplates = [],
|
||||
error,
|
||||
}: {
|
||||
onChange?: (value: SelectedTemplateValue) => void;
|
||||
appTemplates?: Array<Partial<AppTemplate>>;
|
||||
customTemplates?: Array<Partial<CustomTemplate>>;
|
||||
error?: string;
|
||||
} = {}) {
|
||||
server.use(
|
||||
http.get('/api/registries', async () => HttpResponse.json([])),
|
||||
http.get('/api/templates', async () =>
|
||||
HttpResponse.json({ templates: appTemplates, version: '3' })
|
||||
),
|
||||
http.get('/api/custom_templates', async () =>
|
||||
HttpResponse.json(customTemplates)
|
||||
)
|
||||
);
|
||||
|
||||
renderWithQueryClient(
|
||||
<TemplateSelector
|
||||
value={{ template: undefined, type: undefined }}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
|
||||
return { select };
|
||||
|
||||
async function select(
|
||||
type: 'app' | 'custom',
|
||||
template: { Title: string; Description: string }
|
||||
) {
|
||||
const templateSelectorElement = screen.getByLabelText('Template');
|
||||
await selectEvent.select(
|
||||
templateSelectorElement,
|
||||
`${template.Title} - ${template.Description}`
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
template: expect.objectContaining(template),
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo } from 'react';
|
||||
import { GroupBase } from 'react-select';
|
||||
|
||||
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
||||
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
|
||||
|
||||
import { SelectedTemplateValue } from './types';
|
||||
|
||||
export function TemplateSelector({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
value: SelectedTemplateValue;
|
||||
onChange: (value: SelectedTemplateValue) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
const { getTemplate, options } = useOptions();
|
||||
|
||||
return (
|
||||
<FormControl label="Template" inputId="template_selector" errors={error}>
|
||||
<ReactSelect
|
||||
inputId="template_selector"
|
||||
formatGroupLabel={GroupLabel}
|
||||
placeholder="Select an Edge stack template"
|
||||
value={{
|
||||
label: value.template?.Title,
|
||||
id: value.template?.Id,
|
||||
type: value.type,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (!value) {
|
||||
onChange({
|
||||
template: undefined,
|
||||
type: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, type } = value;
|
||||
if (!id || type === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplate({ id, type });
|
||||
onChange({ template, type } as SelectedTemplateValue);
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
function useOptions() {
|
||||
const customTemplatesQuery = useCustomTemplates({
|
||||
params: {
|
||||
edge: true,
|
||||
},
|
||||
});
|
||||
|
||||
const appTemplatesQuery = useAppTemplates({
|
||||
select: (templates) =>
|
||||
templates.filter(
|
||||
(template) =>
|
||||
template.Categories.includes('edge') &&
|
||||
template.Type !== TemplateType.Container
|
||||
),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: 'Edge App Templates',
|
||||
options:
|
||||
appTemplatesQuery.data?.map((template) => ({
|
||||
label: `${template.Title} - ${template.Description}`,
|
||||
id: template.Id,
|
||||
type: 'app' as 'app' | 'custom',
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
label: 'Edge Custom Templates',
|
||||
options:
|
||||
customTemplatesQuery.data && customTemplatesQuery.data.length > 0
|
||||
? customTemplatesQuery.data.map((template) => ({
|
||||
label: `${template.Title} - ${template.Description}`,
|
||||
id: template.Id,
|
||||
type: 'custom' as 'app' | 'custom',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
label: 'No edge custom templates available',
|
||||
id: 0,
|
||||
type: 'custom' as 'app' | 'custom',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const,
|
||||
[appTemplatesQuery.data, customTemplatesQuery.data]
|
||||
);
|
||||
|
||||
return { options, getTemplate };
|
||||
|
||||
function getTemplate({ type, id }: { type: 'app' | 'custom'; id: number }) {
|
||||
if (type === 'app') {
|
||||
const template = appTemplatesQuery.data?.find(
|
||||
(template) => template.Id === id
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`App template not found: ${id}`);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
const template = customTemplatesQuery.data?.find(
|
||||
(template) => template.Id === id
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Custom template not found: ${id}`);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
}
|
||||
|
||||
function GroupLabel({ label }: GroupBase<unknown>) {
|
||||
return (
|
||||
<span className="font-bold text-black th-dark:text-white th-highcontrast:text-white">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
|
||||
export type SelectedTemplateValue =
|
||||
| { template: CustomTemplate; type: 'custom' }
|
||||
| { template: TemplateViewModel; type: 'app' }
|
||||
| { template: undefined; type: undefined };
|
||||
|
||||
export type Values = {
|
||||
file?: string;
|
||||
variables: VariablesFieldValue;
|
||||
envVars: Record<string, string>;
|
||||
} & SelectedTemplateValue;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mixed, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
import { envVarsFieldsetValidation } from './EnvVarsFieldset';
|
||||
|
||||
export function validation({
|
||||
definitions,
|
||||
}: {
|
||||
definitions: VariableDefinition[];
|
||||
}) {
|
||||
return object({
|
||||
type: string().oneOf(['custom', 'app']).required(),
|
||||
envVars: envVarsFieldsetValidation()
|
||||
.optional()
|
||||
.when('type', {
|
||||
is: 'app',
|
||||
then: (schema: SchemaOf<unknown, never>) => schema.required(),
|
||||
}),
|
||||
file: mixed().optional(),
|
||||
template: object().optional().default(null),
|
||||
variables: variablesFieldValidation(definitions)
|
||||
.optional()
|
||||
.when('type', {
|
||||
is: 'custom',
|
||||
then: (schema) => schema.required(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export { validation as templateFieldsetValidation };
|
||||
@@ -1,37 +1,22 @@
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
|
||||
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { DeployFormWidget } from './DeployForm';
|
||||
|
||||
export function AppTemplatesView() {
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
|
||||
'template',
|
||||
(param) => (param ? parseInt(param, 10) : 0)
|
||||
);
|
||||
const templatesQuery = useAppTemplates();
|
||||
const selectedTemplate = selectedTemplateId
|
||||
? templatesQuery.data?.find(
|
||||
(template) => template.Id === selectedTemplateId
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Application templates list" breadcrumbs="Templates" />
|
||||
{selectedTemplate && (
|
||||
<DeployFormWidget
|
||||
template={selectedTemplate}
|
||||
unselect={() => setSelectedTemplateId()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppTemplatesList
|
||||
templates={templatesQuery.data}
|
||||
selectedId={selectedTemplateId}
|
||||
onSelect={(template) => setSelectedTemplateId(template.Id)}
|
||||
templateLinkParams={(template) => ({
|
||||
to: 'edge.stacks.new',
|
||||
params: { templateId: template.Id, templateType: 'app' },
|
||||
})}
|
||||
disabledTypes={[TemplateType.Container]}
|
||||
fixedCategories={['edge']}
|
||||
storageKey="edge-app-templates"
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { Rocket } from 'lucide-react';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { array, lazy, number, object, string } from 'yup';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
import { FallbackImage } from '@@/FallbackImage';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector';
|
||||
import {
|
||||
NameField,
|
||||
nameValidation,
|
||||
} from '../../edge-stacks/CreateView/NameField';
|
||||
import { EdgeGroup } from '../../edge-groups/types';
|
||||
import { DeploymentType, EdgeStack } from '../../edge-stacks/types';
|
||||
import { useEdgeStacks } from '../../edge-stacks/queries/useEdgeStacks';
|
||||
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
|
||||
import { useCreateEdgeStack } from '../../edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack';
|
||||
|
||||
import { EnvVarsFieldset } from './EnvVarsFieldset';
|
||||
|
||||
export function DeployFormWidget({
|
||||
template,
|
||||
unselect,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
unselect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Title
|
||||
icon={
|
||||
<FallbackImage
|
||||
src={template.Logo}
|
||||
fallbackIcon={<Icon icon={Rocket} />}
|
||||
/>
|
||||
}
|
||||
title={template.Title}
|
||||
/>
|
||||
<Widget.Body>
|
||||
<DeployForm template={template} unselect={unselect} />
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
edgeGroupIds: Array<EdgeGroup['Id']>;
|
||||
envVars: Record<string, string>;
|
||||
}
|
||||
|
||||
function DeployForm({
|
||||
template,
|
||||
unselect,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
unselect: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const mutation = useCreateEdgeStack();
|
||||
const edgeStacksQuery = useEdgeStacks();
|
||||
const edgeGroupsQuery = useEdgeGroups({
|
||||
select: (groups) =>
|
||||
Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
|
||||
});
|
||||
|
||||
const initialValues: FormValues = {
|
||||
edgeGroupIds: [],
|
||||
name: template.Name || '',
|
||||
envVars:
|
||||
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
|
||||
{},
|
||||
};
|
||||
|
||||
if (!edgeStacksQuery.data || !edgeGroupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={() =>
|
||||
validation(edgeStacksQuery.data, edgeGroupsQuery.data)
|
||||
}
|
||||
validateOnMount
|
||||
>
|
||||
{({ values, errors, setFieldValue, isValid }) => (
|
||||
<Form className="form-horizontal">
|
||||
<NameField
|
||||
value={values.name}
|
||||
onChange={(v) => setFieldValue('name', v)}
|
||||
errors={errors.name}
|
||||
/>
|
||||
|
||||
<EdgeGroupsSelector
|
||||
horizontal
|
||||
value={values.edgeGroupIds}
|
||||
error={errors.edgeGroupIds}
|
||||
onChange={(value) => setFieldValue('edgeGroupIds', value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<EnvVarsFieldset
|
||||
value={values.envVars}
|
||||
options={template.Env}
|
||||
errors={errors.envVars}
|
||||
onChange={(values) => setFieldValue('envVars', values)}
|
||||
/>
|
||||
|
||||
<FormActions
|
||||
isLoading={mutation.isLoading}
|
||||
isValid={isValid}
|
||||
loadingText="Deployment in progress..."
|
||||
submitLabel="Deploy the stack"
|
||||
>
|
||||
<Button type="reset" onClick={() => unselect()} color="default">
|
||||
Hide
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
return mutation.mutate(
|
||||
{
|
||||
method: 'git',
|
||||
payload: {
|
||||
name: values.name,
|
||||
edgeGroups: values.edgeGroupIds,
|
||||
deploymentType: DeploymentType.Compose,
|
||||
|
||||
envVars: Object.entries(values.envVars).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
})),
|
||||
git: {
|
||||
RepositoryURL: template.Repository.url,
|
||||
ComposeFilePathInRepository: template.Repository.stackfile,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Edge Stack created');
|
||||
router.stateService.go('edge.stacks');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validation(
|
||||
stacks: EdgeStack[],
|
||||
edgeGroupsType: Record<EdgeGroup['Id'], Array<EnvironmentType>>
|
||||
) {
|
||||
return lazy((values: FormValues) => {
|
||||
const types = getTypes(values.edgeGroupIds);
|
||||
|
||||
return object({
|
||||
name: nameValidation(
|
||||
stacks,
|
||||
types?.includes(EnvironmentType.EdgeAgentOnDocker)
|
||||
),
|
||||
edgeGroupIds: array(number().required().default(0))
|
||||
.min(1, 'At least one group is required')
|
||||
.test(
|
||||
'same-type',
|
||||
'Groups should be of the same type',
|
||||
(value) => _.uniq(getTypes(value)).length === 1
|
||||
),
|
||||
envVars: array()
|
||||
.transform((_, orig) => Object.values(orig))
|
||||
.of(string().required('Required')),
|
||||
});
|
||||
});
|
||||
|
||||
function getTypes(value: number[] | undefined) {
|
||||
return value?.flatMap((g) => edgeGroupsType[g]);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function ListView() {
|
||||
onDelete={handleDelete}
|
||||
templateLinkParams={(template) => ({
|
||||
to: 'edge.stacks.new',
|
||||
params: { templateId: template.Id },
|
||||
params: { templateId: template.Id, templateType: 'custom' },
|
||||
})}
|
||||
storageKey="edge-custom-templates"
|
||||
/>
|
||||
|
||||
28
app/react/hooks/useAdminOnlyRedirect.ts
Normal file
28
app/react/hooks/useAdminOnlyRedirect.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { RawParams, useRouter } from '@uirouter/react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCurrentUser } from './useUser';
|
||||
|
||||
type RedirectOptions = {
|
||||
to: string;
|
||||
params?: RawParams;
|
||||
};
|
||||
|
||||
/**
|
||||
* Redirects to the given route if the user is not a Portainer admin.
|
||||
* @param to The route to redirect to (default is `'portainer.home'`).
|
||||
* @param params The params to pass to the route.
|
||||
*/
|
||||
export function useAdminOnlyRedirect(
|
||||
{ to, params }: RedirectOptions = { to: 'portainer.home' }
|
||||
) {
|
||||
const router = useRouter();
|
||||
|
||||
const { isAdmin } = useCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
router.stateService.go(to, params);
|
||||
}
|
||||
}, [isAdmin, to, params, router.stateService]);
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { EnvironmentId } from '../portainer/environments/types';
|
||||
|
||||
import { useAuthorizations } from './useUser';
|
||||
|
||||
type AuthorizationOptions = {
|
||||
authorizations: string | string[];
|
||||
forceEnvironmentId?: EnvironmentId;
|
||||
adminOnlyCE?: boolean;
|
||||
};
|
||||
|
||||
@@ -19,24 +16,19 @@ type RedirectOptions = {
|
||||
/**
|
||||
* Redirects to the given route if the user is not authorized.
|
||||
* @param authorizations The authorizations to check.
|
||||
* @param forceEnvironmentId The environment id to use for the check.
|
||||
* @param adminOnlyCE Whether to check only for admin authorizations in CE.
|
||||
* @param adminOnlyCE Whether to allow non-admin users in CE.
|
||||
* @param to The route to redirect to.
|
||||
* @param params The params to pass to the route.
|
||||
*/
|
||||
export function useUnauthorizedRedirect(
|
||||
{
|
||||
authorizations,
|
||||
forceEnvironmentId,
|
||||
adminOnlyCE = false,
|
||||
}: AuthorizationOptions,
|
||||
{ authorizations, adminOnlyCE = false }: AuthorizationOptions,
|
||||
{ to, params }: RedirectOptions
|
||||
) {
|
||||
const router = useRouter();
|
||||
|
||||
const isAuthorized = useAuthorizations(
|
||||
authorizations,
|
||||
forceEnvironmentId,
|
||||
undefined,
|
||||
adminOnlyCE
|
||||
);
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
import { isAdmin } from '@/portainer/users/user.helpers';
|
||||
import { isEdgeAdmin, isPureAdmin } from '@/portainer/users/user.helpers';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { User } from '@/portainer/users/types';
|
||||
import { useLoadCurrentUser } from '@/portainer/users/queries/useLoadCurrentUser';
|
||||
|
||||
import { useEnvironment } from '../portainer/environments/queries';
|
||||
import { isBE } from '../portainer/feature-flags/feature-flags.service';
|
||||
|
||||
interface State {
|
||||
user?: User;
|
||||
}
|
||||
@@ -39,32 +42,84 @@ export function useCurrentUser() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isAdmin: isAdmin(user),
|
||||
isPureAdmin: isPureAdmin(user),
|
||||
}),
|
||||
[user]
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsPureAdmin() {
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
return isPureAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the admin status of the user, (admin >= edge admin)
|
||||
* @param forceEnvironmentId to force the environment id, used where the environment id can't be loaded from the router, like sidebar
|
||||
* @returns query result with isLoading and isAdmin - isAdmin is true if the user edge admin or admin.
|
||||
*/
|
||||
export function useIsEdgeAdmin({
|
||||
forceEnvironmentId,
|
||||
noEnvScope,
|
||||
}: {
|
||||
forceEnvironmentId?: EnvironmentId;
|
||||
noEnvScope?: boolean;
|
||||
} = {}) {
|
||||
const { user } = useCurrentUser();
|
||||
const {
|
||||
params: { endpointId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const envId = forceEnvironmentId || endpointId;
|
||||
const envScope = typeof noEnvScope === 'boolean' ? !noEnvScope : !!envId;
|
||||
const envQuery = useEnvironment(envScope ? envId : undefined);
|
||||
|
||||
if (!envScope) {
|
||||
return { isLoading: false, isAdmin: isEdgeAdmin(user) };
|
||||
}
|
||||
|
||||
if (envQuery.isLoading) {
|
||||
return { isLoading: true, isAdmin: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: false,
|
||||
isAdmin: isEdgeAdmin(user, envQuery.data),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthorizations(
|
||||
authorizations: string | string[],
|
||||
forceEnvironmentId?: EnvironmentId,
|
||||
adminOnlyCE = false
|
||||
) {
|
||||
const { user } = useUser();
|
||||
const { user } = useCurrentUser();
|
||||
const {
|
||||
params: { endpointId },
|
||||
} = useCurrentStateAndParams();
|
||||
const envQuery = useEnvironment(forceEnvironmentId || endpointId);
|
||||
const isAdmin = useIsEdgeAdmin({ forceEnvironmentId });
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
return { authorized: false, isLoading: false };
|
||||
}
|
||||
|
||||
return hasAuthorizations(
|
||||
user,
|
||||
authorizations,
|
||||
forceEnvironmentId || endpointId,
|
||||
adminOnlyCE
|
||||
);
|
||||
if (envQuery.isLoading) {
|
||||
return { authorized: false, isLoading: true };
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
return { authorized: true, isLoading: false };
|
||||
}
|
||||
|
||||
if (!isBE && adminOnlyCE) {
|
||||
return { authorized: false, isLoading: false };
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: hasAuthorizations(user, authorizations, envQuery.data?.Id),
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useIsEnvironmentAdmin({
|
||||
@@ -81,24 +136,18 @@ export function useIsEnvironmentAdmin({
|
||||
);
|
||||
}
|
||||
|
||||
export function isEnvironmentAdmin(
|
||||
user: User,
|
||||
environmentId: EnvironmentId,
|
||||
adminOnlyCE = true
|
||||
) {
|
||||
return hasAuthorizations(
|
||||
user,
|
||||
['EndpointResourcesAccess'],
|
||||
environmentId,
|
||||
adminOnlyCE
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAuthorizations(
|
||||
/**
|
||||
* will return true if the user has the authorizations. assumes the user is authenticated and not an admin
|
||||
* @param user
|
||||
* @param authorizations
|
||||
* @param environmentId
|
||||
* @param adminOnlyCE
|
||||
* @returns
|
||||
*/
|
||||
function hasAuthorizations(
|
||||
user: User,
|
||||
authorizations: string | string[],
|
||||
environmentId?: EnvironmentId,
|
||||
adminOnlyCE = false
|
||||
environmentId?: EnvironmentId
|
||||
) {
|
||||
const authorizationsArray =
|
||||
typeof authorizations === 'string' ? [authorizations] : authorizations;
|
||||
@@ -107,26 +156,13 @@ export function hasAuthorizations(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.env.PORTAINER_EDITION === 'CE') {
|
||||
return !adminOnlyCE || isAdmin(user);
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
const userEndpointAuthorizations =
|
||||
user.EndpointAuthorizations?.[environmentId] || [];
|
||||
|
||||
if (
|
||||
!user.EndpointAuthorizations ||
|
||||
!user.EndpointAuthorizations[environmentId]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userEndpointAuthorizations = user.EndpointAuthorizations[environmentId];
|
||||
return authorizationsArray.some(
|
||||
(authorization) => userEndpointAuthorizations[authorization]
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { InsightsBox } from '@@/InsightsBox';
|
||||
import { Link } from '@@/Link';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
@@ -13,6 +12,7 @@ type Props = {
|
||||
setStackName: (name: string) => void;
|
||||
stacks?: string[];
|
||||
inputClassName?: string;
|
||||
textTip?: string;
|
||||
};
|
||||
|
||||
export function StackName({
|
||||
@@ -20,12 +20,16 @@ export function StackName({
|
||||
setStackName,
|
||||
stacks = [],
|
||||
inputClassName,
|
||||
textTip = "Enter or select a 'stack' name to group multiple deployments together, or else leave empty to ignore.",
|
||||
}: Props) {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const stackResults = useMemo(
|
||||
() => stacks.filter((stack) => stack.includes(stackName ?? '')),
|
||||
[stacks, stackName]
|
||||
);
|
||||
|
||||
const { isAdmin } = isAdminQuery;
|
||||
|
||||
const tooltip = (
|
||||
<>
|
||||
You may specify a stack name to label resources that you want to group.
|
||||
@@ -44,37 +48,10 @@ export function StackName({
|
||||
</>
|
||||
);
|
||||
|
||||
const insightsBoxContent = (
|
||||
<>
|
||||
The stack field below was previously labelled 'Name' but, in
|
||||
fact, it's always been the stack name (hence the relabelling).
|
||||
{isAdmin && (
|
||||
<>
|
||||
<br />
|
||||
Kubernetes Stacks functionality can be turned off entirely via{' '}
|
||||
<Link to="portainer.settings" target="_blank">
|
||||
Kubernetes Settings
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-fit mb-4">
|
||||
<InsightsBox
|
||||
type="slim"
|
||||
header="Stack"
|
||||
content={insightsBoxContent}
|
||||
insightCloseId="k8s-stacks-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextTip className="mb-4" color="blue">
|
||||
Enter or select a 'stack' name to group multiple deployments
|
||||
together, or else leave empty to ignore.
|
||||
{textTip}
|
||||
</TextTip>
|
||||
<div className="form-group">
|
||||
<label
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { InsightsBox } from '@@/InsightsBox';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export function StackNameLabelInsight() {
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
const insightsBoxContent = (
|
||||
<>
|
||||
The stack field below was previously labelled 'Name' but, in
|
||||
fact, it's always been the stack name (hence the relabelling).
|
||||
{isPureAdmin && (
|
||||
<>
|
||||
<br />
|
||||
Kubernetes Stacks functionality can be turned off entirely via{' '}
|
||||
<Link to="portainer.settings" target="_blank">
|
||||
Kubernetes Settings
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<InsightsBox
|
||||
type="slim"
|
||||
header="Stack"
|
||||
content={insightsBoxContent}
|
||||
insightCloseId="k8s-stacks-name"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
@@ -39,7 +39,8 @@ export function LoadBalancerServicesForm({
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: loadBalancerEnabled, ...loadBalancerEnabledQuery } =
|
||||
useEnvironment(
|
||||
@@ -47,6 +48,12 @@ export function LoadBalancerServicesForm({
|
||||
(environment) => environment?.Kubernetes.Configuration.UseLoadBalancer
|
||||
);
|
||||
|
||||
if (isAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isAdmin } = isAdminQuery;
|
||||
|
||||
const loadBalancerServiceCount = services.filter(
|
||||
(service) => service.Type === 'LoadBalancer'
|
||||
).length;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { InsightsBox } from '@@/InsightsBox';
|
||||
|
||||
export function HelmInsightsBox() {
|
||||
return (
|
||||
<InsightsBox
|
||||
header="Helm option"
|
||||
content={
|
||||
<span>
|
||||
From 2.20 and on, the Helm menu sidebar option has moved to the{' '}
|
||||
<strong>Create from manifest screen</strong> - accessed via the button
|
||||
above.
|
||||
</span>
|
||||
}
|
||||
insightCloseId="k8s-helm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import { createStore } from '@/react/kubernetes/datatables/default-kube-datatabl
|
||||
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { InsightsBox } from '@@/InsightsBox';
|
||||
|
||||
import { KubernetesStack } from '../../types';
|
||||
import { HelmInsightsBox } from '../ApplicationsDatatable/HelmInsightsBox';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { SubRows } from './SubRows';
|
||||
@@ -88,11 +88,7 @@ export function ApplicationsStacksDatatable({
|
||||
/>
|
||||
|
||||
<div className="w-fit">
|
||||
<InsightsBox
|
||||
type="slim"
|
||||
header="From 2.18 on, you can filter this view by namespace."
|
||||
insightCloseId="k8s-namespace-filtering"
|
||||
/>
|
||||
<HelmInsightsBox />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { Link } from '@@/Link';
|
||||
@@ -113,7 +113,13 @@ export function AutoScalingFormSection({
|
||||
}
|
||||
|
||||
function NoMetricsServerWarning() {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
if (isAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isAdmin } = isAdminQuery;
|
||||
|
||||
return (
|
||||
<TextTip color="orange">
|
||||
{isAdmin && (
|
||||
|
||||
@@ -12,7 +12,6 @@ export function ConfigureView() {
|
||||
useUnauthorizedRedirect(
|
||||
{
|
||||
authorizations: 'K8sClusterW',
|
||||
forceEnvironmentId: environment?.Id,
|
||||
adminOnlyCE: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { formatDate } from '@/portainer/filters/filters';
|
||||
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
|
||||
|
||||
import { ConfigMapRowData } from '../types';
|
||||
import { configurationOwnerUsernameLabel } from '../../../constants';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -12,7 +14,8 @@ export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
|
||||
|
||||
function getCreatedAtText(row: ConfigMapRowData) {
|
||||
const owner =
|
||||
row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
|
||||
row.metadata?.labels?.[appOwnerLabel];
|
||||
const date = formatDate(row.metadata?.creationTimestamp);
|
||||
return owner ? `${date} by ${owner}` : date;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { ConfigMapRowData } from '../types';
|
||||
import { configurationOwnerUsernameLabel } from '../../../constants';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -16,8 +18,11 @@ export const name = columnHelper.accessor(
|
||||
const isSystemToken = name?.includes('default-token-');
|
||||
const isSystemConfigMap = isSystemToken || row.isSystem;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
const hasConfigurationOwner = !!(
|
||||
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
|
||||
row.metadata?.labels?.[appOwnerLabel]
|
||||
);
|
||||
|
||||
return `${name} ${isSystemConfigMap ? 'system' : ''} ${
|
||||
!isSystemToken && !hasConfigurationOwner ? 'external' : ''
|
||||
} ${!row.inUse && !isSystemConfigMap ? 'unused' : ''}`;
|
||||
@@ -35,10 +40,10 @@ function Cell({ row }: CellContext<ConfigMapRowData, string>) {
|
||||
const isSystemToken = name?.includes('default-token-');
|
||||
const isSystemConfigMap = isSystemToken || row.original.isSystem;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.original.metadata?.labels?.[
|
||||
'io.portainer.kubernetes.configuration.owner'
|
||||
];
|
||||
const hasConfigurationOwner = !!(
|
||||
row.original.metadata?.labels?.[configurationOwnerUsernameLabel] ||
|
||||
row.original.metadata?.labels?.[appOwnerLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sConfigMapsR" childrenUnauthorized={name}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { formatDate } from '@/portainer/filters/filters';
|
||||
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
|
||||
|
||||
import { SecretRowData } from '../types';
|
||||
import { configurationOwnerUsernameLabel } from '../../../constants';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -12,7 +14,8 @@ export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
|
||||
|
||||
function getCreatedAtText(row: SecretRowData) {
|
||||
const owner =
|
||||
row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
|
||||
row.metadata?.labels?.[appOwnerLabel];
|
||||
const date = formatDate(row.metadata?.creationTimestamp);
|
||||
return owner ? `${date} by ${owner}` : date;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { SecretRowData } from '../types';
|
||||
import { configurationOwnerUsernameLabel } from '../../../constants';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -18,8 +20,10 @@ export const name = columnHelper.accessor(
|
||||
row.metadata?.annotations?.['portainer.io/registry.id'];
|
||||
const isSystemSecret = isSystemToken || row.isSystem || isRegistrySecret;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
const hasConfigurationOwner = !!(
|
||||
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
|
||||
row.metadata?.labels?.[appOwnerLabel]
|
||||
);
|
||||
return `${name} ${isSystemSecret ? 'system' : ''} ${
|
||||
!isSystemToken && !hasConfigurationOwner ? 'external' : ''
|
||||
} ${!row.inUse && !isSystemSecret ? 'unused' : ''}`;
|
||||
@@ -37,10 +41,10 @@ function Cell({ row }: CellContext<SecretRowData, string>) {
|
||||
const isSystemToken = name?.includes('default-token-');
|
||||
const isSystemSecret = isSystemToken || row.original.isSystem;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.original.metadata?.labels?.[
|
||||
'io.portainer.kubernetes.configuration.owner'
|
||||
];
|
||||
const hasConfigurationOwner = !!(
|
||||
row.original.metadata?.labels?.[configurationOwnerUsernameLabel] ||
|
||||
row.original.metadata?.labels?.[appOwnerLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sSecretsR" childrenUnauthorized={name}>
|
||||
|
||||
2
app/react/kubernetes/configs/constants.ts
Normal file
2
app/react/kubernetes/configs/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const configurationOwnerUsernameLabel =
|
||||
'io.portainer.kubernetes.configuration.owner';
|
||||
@@ -828,6 +828,7 @@ export function CreateIngressView() {
|
||||
Paths: preparePaths(rule.IngressName, rule.Hosts),
|
||||
TLS: prepareTLS(rule.Hosts),
|
||||
Annotations: prepareAnnotations(rule.Annotations || []),
|
||||
Labels: rule.Labels,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Rule {
|
||||
Hosts: Host[];
|
||||
Annotations?: Annotation[];
|
||||
IngressType?: string;
|
||||
Labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ServicePorts {
|
||||
|
||||
@@ -104,6 +104,7 @@ export function prepareRuleFromIngress(
|
||||
Hosts: prepareRuleHostsFromIngress(ing) || [],
|
||||
Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [],
|
||||
IngressType: ing.Type,
|
||||
Labels: ing.Labels,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatDate } from '@/portainer/filters/filters';
|
||||
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -13,7 +14,8 @@ export const created = columnHelper.accessor(
|
||||
cell: ({ row, getValue }) => {
|
||||
const date = formatDate(getValue());
|
||||
const owner =
|
||||
row.original.Labels?.['io.portainer.kubernetes.ingress.owner'];
|
||||
row.original.Labels?.['io.portainer.kubernetes.ingress.owner'] ||
|
||||
row.original.Labels?.[appOwnerLabel];
|
||||
|
||||
return owner ? `${date} by ${owner}` : date;
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ export function CreateNamespaceView() {
|
||||
useUnauthorizedRedirect(
|
||||
{
|
||||
authorizations: 'K8sResourcePoolsW',
|
||||
forceEnvironmentId: environmentId,
|
||||
adminOnlyCE: !isBE,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,13 +19,13 @@ export function RegistriesSelector({
|
||||
options = [],
|
||||
inputId,
|
||||
}: Props) {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.length === 0 && (
|
||||
<p className="text-muted text-xs mb-1 mt-2">
|
||||
{isAdmin ? (
|
||||
{isPureAdmin ? (
|
||||
<span>
|
||||
No registries available. Head over to the{' '}
|
||||
<Link to="portainer.registries" target="_blank">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Edit2, Settings } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import {
|
||||
Environment,
|
||||
PlatformType,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { LinkButton } from '@@/LinkButton';
|
||||
|
||||
export function EditButtons({ environment }: { environment: Environment }) {
|
||||
const { isAdmin } = useUser();
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
const isEdgeAsync = checkEdgeAsync(environment);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
|
||||
return (
|
||||
<ButtonsGrid className="ml-3 w-11">
|
||||
<LinkButton
|
||||
disabled={!isAdmin}
|
||||
disabled={!isPureAdmin}
|
||||
to="portainer.endpoints.endpoint"
|
||||
params={{ id: environment.Id, redirectTo: 'portainer.home' }}
|
||||
color="none"
|
||||
@@ -42,7 +42,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
|
||||
/>
|
||||
|
||||
<LinkButton
|
||||
disabled={!configRoute || isEdgeAsync || !isAdmin}
|
||||
disabled={!configRoute || isEdgeAsync || !isPureAdmin}
|
||||
to={configRoute}
|
||||
params={{ endpointId: environment.Id }}
|
||||
color="none"
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { useIsPureAdmin } from '@/react/hooks/useUser';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { environmentStore } from '@/react/hooks/current-environment-store';
|
||||
|
||||
@@ -46,7 +46,7 @@ interface Props {
|
||||
const storageKey = 'home_endpoints';
|
||||
|
||||
export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
const isPureAdmin = useIsPureAdmin();
|
||||
const currentEnvStore = useStore(environmentStore);
|
||||
|
||||
const [platformTypes, setPlatformTypes] = useHomePageFilter<PlatformType[]>(
|
||||
@@ -138,7 +138,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
||||
{totalAvailable === 0 && (
|
||||
<NoEnvironmentsInfoPanel isAdmin={isPureAdmin} />
|
||||
)}
|
||||
|
||||
<TableContainer>
|
||||
<div className="px-4">
|
||||
@@ -160,7 +162,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
||||
placeholder="Search by name, group, tag, status, URL..."
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
{isAdmin && (
|
||||
{isPureAdmin && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
data-cy="home-refreshEndpointsButton"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
@@ -26,7 +26,13 @@ export function AccessControlForm({
|
||||
errors,
|
||||
environmentId,
|
||||
}: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
if (isAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isAdmin } = isAdminQuery;
|
||||
|
||||
const accessControlEnabled =
|
||||
values.ownership !== ResourceControlOwnership.PUBLIC;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useReducer } from 'react';
|
||||
import { Edit, Eye } from 'lucide-react';
|
||||
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
import { TeamMembership, TeamRole } from '@/react/portainer/users/teams/types';
|
||||
import { useIsTeamLeader, useUserMembership } from '@/portainer/users/queries';
|
||||
import {
|
||||
useIsCurrentUserTeamLeader,
|
||||
useUserMembership,
|
||||
} from '@/portainer/users/queries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
import { TableContainer, TableTitle } from '@@/datatables';
|
||||
import { Button } from '@@/buttons';
|
||||
@@ -34,20 +37,27 @@ export function AccessControlPanel({
|
||||
onUpdateSuccess,
|
||||
}: Props) {
|
||||
const [isEditMode, toggleEditMode] = useReducer((state) => !state, false);
|
||||
const { user, isAdmin } = useUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const isTeamLeader = useIsCurrentUserTeamLeader();
|
||||
|
||||
const isInherited = checkIfInherited();
|
||||
|
||||
const restrictions = useRestrictions(resourceControl);
|
||||
|
||||
if (isAdminQuery.isLoading || !restrictions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isPartOfRestrictedUsers, isLeaderOfAnyRestrictedTeams } =
|
||||
useRestrictions(resourceControl);
|
||||
restrictions;
|
||||
|
||||
const { isAdmin } = isAdminQuery;
|
||||
|
||||
const isEditDisabled =
|
||||
disableOwnershipChange ||
|
||||
isInherited ||
|
||||
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
|
||||
|
||||
const isTeamLeader = useIsTeamLeader(user) as boolean;
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<TableTitle label="Access control" icon={Eye} />
|
||||
@@ -106,10 +116,16 @@ export function AccessControlPanel({
|
||||
}
|
||||
|
||||
function useRestrictions(resourceControl?: ResourceControlViewModel) {
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const memberships = useUserMembership(user.Id);
|
||||
|
||||
if (isAdminQuery.isLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { isAdmin } = isAdminQuery;
|
||||
|
||||
if (!resourceControl || isAdmin) {
|
||||
return {
|
||||
isPartOfRestrictedUsers: false,
|
||||
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx';
|
||||
import { useMutation } from 'react-query';
|
||||
import { object } from 'yup';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
@@ -43,7 +43,8 @@ export function AccessControlPanelForm({
|
||||
onCancelClick,
|
||||
onUpdateSuccess,
|
||||
}: Props) {
|
||||
const { isAdmin, user } = useCurrentUser();
|
||||
const { user } = useCurrentUser();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
const updateAccess = useMutation(
|
||||
(variables: AccessControlFormData) =>
|
||||
@@ -63,6 +64,12 @@ export function AccessControlPanelForm({
|
||||
}
|
||||
);
|
||||
|
||||
if (isAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isAdmin } = isAdminQuery;
|
||||
|
||||
const initialValues = {
|
||||
accessControl: parseAccessControlFormData(
|
||||
isAdmin,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
@@ -30,9 +30,10 @@ export function EditDetails({
|
||||
formNamespace,
|
||||
environmentId,
|
||||
}: Props) {
|
||||
const { user, isAdmin } = useUser();
|
||||
const { user, isPureAdmin } = useCurrentUser();
|
||||
|
||||
const { users, teams, isLoading } = useLoadState(environmentId);
|
||||
|
||||
const { users, teams, isLoading } = useLoadState(environmentId, isAdmin);
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
onChange({ ...values, ...partialValues });
|
||||
@@ -41,7 +42,12 @@ export function EditDetails({
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (isLoading || !teams || (isAdmin && !users) || !values.authorizedUsers) {
|
||||
if (
|
||||
isLoading ||
|
||||
!teams ||
|
||||
(isPureAdmin && !users) ||
|
||||
!values.authorizedUsers
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,14 +57,14 @@ export function EditDetails({
|
||||
onChange={handleChangeOwnership}
|
||||
name={withNamespace('ownership')}
|
||||
value={values.ownership}
|
||||
isAdmin={isAdmin}
|
||||
isAdmin={isPureAdmin}
|
||||
isPublicVisible={isPublicVisible}
|
||||
teams={teams}
|
||||
/>
|
||||
|
||||
{values.ownership === ResourceControlOwnership.RESTRICTED && (
|
||||
<div aria-label="extra-options">
|
||||
{isAdmin && (
|
||||
{isPureAdmin && (
|
||||
<UsersField
|
||||
name={withNamespace('authorizedUsers')}
|
||||
users={users || []}
|
||||
@@ -68,12 +74,12 @@ export function EditDetails({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isAdmin || teams.length > 1) && (
|
||||
{(isPureAdmin || teams.length > 1) && (
|
||||
<TeamsField
|
||||
name={withNamespace('authorizedTeams')}
|
||||
teams={teams}
|
||||
overrideTooltip={
|
||||
!isAdmin && teams.length > 1
|
||||
!isPureAdmin && teams.length > 1
|
||||
? 'As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
@@ -111,7 +117,7 @@ export function EditDetails({
|
||||
// Non admin team leaders/members under only one team can
|
||||
// automatically grant the resource access to all members
|
||||
// under the team
|
||||
if (!isAdmin && teams && teams.length === 1) {
|
||||
if (!isPureAdmin && teams && teams.length === 1) {
|
||||
authorizedTeams = teams.map((team) => team.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
|
||||
export function useLoadState(environmentId: EnvironmentId, enabled = true) {
|
||||
export function useLoadState(environmentId: EnvironmentId) {
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const teams = useTeams(false, environmentId);
|
||||
|
||||
const users = useUsers(false, environmentId, enabled);
|
||||
const users = useUsers(false, environmentId, isAdminQuery.isAdmin);
|
||||
|
||||
return {
|
||||
teams: teams.data,
|
||||
users: users.data,
|
||||
isLoading: teams.isLoading || users.isLoading,
|
||||
isAdmin: isAdminQuery.isAdmin,
|
||||
isLoading: teams.isLoading || users.isLoading || isAdminQuery.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
@@ -40,6 +41,19 @@ export function HelmRepositoryDatatable() {
|
||||
helmReposQuery.data?.UserRepositories,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// window.location.hash will get everything after the hashbang
|
||||
// the regex will match the the content after each hash
|
||||
const timeout = setTimeout(() => {
|
||||
const regEx = /#!.*#(.*)/;
|
||||
const match = window.location.hash.match(regEx);
|
||||
if (match && match[1]) {
|
||||
document.getElementById(match[1])?.scrollIntoView();
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
getRowId={(row) => String(row.Id)}
|
||||
@@ -47,8 +61,9 @@ export function HelmRepositoryDatatable() {
|
||||
description={<HelmDatatableDescription />}
|
||||
settingsManager={tableState}
|
||||
columns={columns}
|
||||
title="Helm Repositories"
|
||||
title="Helm repositories"
|
||||
titleIcon={helm}
|
||||
titleId="helm-repositories"
|
||||
renderTableActions={(selectedRows) => (
|
||||
<HelmRepositoryDatatableActions selectedItems={selectedRows} />
|
||||
)}
|
||||
@@ -64,8 +79,11 @@ function HelmDatatableDescription() {
|
||||
<TextTip color="blue" className="mb-3">
|
||||
Adding a Helm repo here only makes it available in your own user
|
||||
account's Portainer UI. Helm charts are pulled down from these repos
|
||||
(plus the globally-set Helm repo) and shown in the Create from Manifest
|
||||
screen's Helm charts list.
|
||||
(plus the{' '}
|
||||
<Link to="portainer.settings" params={{ '#': 'kubernetes-settings' }}>
|
||||
<span>globally-set Helm repo</span>
|
||||
</Link>
|
||||
) and shown in the Create from Manifest screen's Helm charts list.
|
||||
</TextTip>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user