mirror of https://github.com/portainer/portainer
refactor(app): migrate remaining form sections [EE-6231] (#10938)
parent
0b9cebc685
commit
4e7d1c7088
|
@ -47,6 +47,15 @@ import {
|
||||||
autoScalingValidation,
|
autoScalingValidation,
|
||||||
} from '@/react/kubernetes/applications/components/AutoScalingFormSection';
|
} from '@/react/kubernetes/applications/components/AutoScalingFormSection';
|
||||||
import { withControlledInput } from '@/react-tools/withControlledInput';
|
import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||||
|
import {
|
||||||
|
NamespaceSelector,
|
||||||
|
namespaceSelectorValidation,
|
||||||
|
} from '@/react/kubernetes/applications/components/NamespaceSelector';
|
||||||
|
import { EditYamlFormSection } from '@/react/kubernetes/applications/components/EditYamlFormSection';
|
||||||
|
import {
|
||||||
|
NameFormSection,
|
||||||
|
appNameValidation,
|
||||||
|
} from '@/react/kubernetes/applications/components/NameFormSection';
|
||||||
|
|
||||||
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
|
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||||
|
|
||||||
|
@ -135,9 +144,17 @@ export const ngModule = angular
|
||||||
withUIRouter(
|
withUIRouter(
|
||||||
withReactQuery(withCurrentUser(withControlledInput(StackName)))
|
withReactQuery(withCurrentUser(withControlledInput(StackName)))
|
||||||
),
|
),
|
||||||
['setStackName', 'isAdmin', 'stackName']
|
['setStackName', 'stackName', 'stacks', 'inputClassName']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.component(
|
||||||
|
'editYamlFormSection',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(EditYamlFormSection))), [
|
||||||
|
'values',
|
||||||
|
'onChange',
|
||||||
|
'isComposeFormat',
|
||||||
|
])
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'applicationSummaryWidget',
|
'applicationSummaryWidget',
|
||||||
r2a(
|
r2a(
|
||||||
|
@ -298,3 +315,21 @@ withFormValidation(
|
||||||
[],
|
[],
|
||||||
placementValidation
|
placementValidation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
withUIRouter(withCurrentUser(NamespaceSelector)),
|
||||||
|
'namespaceSelector',
|
||||||
|
['isEdit'],
|
||||||
|
namespaceSelectorValidation,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
withUIRouter(withCurrentUser(withReactQuery(NameFormSection))),
|
||||||
|
'nameFormSection',
|
||||||
|
['isEdit'],
|
||||||
|
appNameValidation,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
|
@ -63,33 +63,12 @@
|
||||||
<div ng-if="ctrl.isExternalApplication()">
|
<div ng-if="ctrl.isExternalApplication()">
|
||||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> Namespace </div>
|
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> Namespace </div>
|
||||||
<!-- #region NAMESPACE -->
|
<!-- #region NAMESPACE -->
|
||||||
<div class="form-group" ng-if="ctrl.formValues.ResourcePool">
|
<namespace-selector
|
||||||
<label for="resource-pool-selector" class="col-sm-1 control-label text-left">Namespace</label>
|
values="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||||
<div class="col-sm-11">
|
on-change="(ctrl.onChangeNamespaceName)"
|
||||||
<select
|
validation-data="{hasQuota: ctrl.state.resourcePoolHasQuota, isResourceQuotaCapacityExceeded: ctrl.resourceQuotaCapacityExceeded(), namespaceOptionCount: ctrl.resourcePools.length, isAdmin: ctrl.isAdmin}"
|
||||||
class="form-control"
|
is-edit="ctrl.state.isEdit"
|
||||||
id="resource-pool-selector"
|
></namespace-selector>
|
||||||
ng-model="ctrl.formValues.ResourcePool"
|
|
||||||
ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools"
|
|
||||||
ng-change="ctrl.onResourcePoolSelectionChange()"
|
|
||||||
ng-disabled="ctrl.state.isEdit"
|
|
||||||
data-cy="k8sAppCreate-nsSelect"
|
|
||||||
></select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool">
|
|
||||||
<div class="col-sm-12 small text-danger">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'danger'"></pr-icon>
|
|
||||||
This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
|
|
||||||
namespace.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-if="!ctrl.formValues.ResourcePool">
|
|
||||||
<div class="col-sm-12 small text-muted">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
|
|
||||||
You do not have access to any namespace. Contact your administrator to get access to a namespace.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- kubernetes services options -->
|
<!-- kubernetes services options -->
|
||||||
<div ng-if="ctrl.formValues.ResourcePool">
|
<div ng-if="ctrl.formValues.ResourcePool">
|
||||||
<kube-services-form
|
<kube-services-form
|
||||||
|
@ -177,81 +156,22 @@
|
||||||
type="'application'"
|
type="'application'"
|
||||||
></git-form-info-panel>
|
></git-form-info-panel>
|
||||||
<!-- #region NAMESPACE -->
|
<!-- #region NAMESPACE -->
|
||||||
<div class="form-group" ng-if="ctrl.formValues.ResourcePool">
|
<namespace-selector
|
||||||
<label for="resource-pool-selector" class="col-sm-3 col-lg-2 control-label text-left">Namespace</label>
|
values="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||||
<div class="col-sm-8">
|
on-change="(ctrl.onChangeNamespaceName)"
|
||||||
<select
|
validation-data="{hasQuota: ctrl.state.resourcePoolHasQuota, isResourceQuotaCapacityExceeded: ctrl.resourceQuotaCapacityExceeded(), namespaceOptionCount: ctrl.resourcePools.length, isAdmin: ctrl.isAdmin}"
|
||||||
class="form-control"
|
is-edit="ctrl.state.isEdit"
|
||||||
id="resource-pool-selector"
|
></namespace-selector>
|
||||||
ng-model="ctrl.formValues.ResourcePool"
|
|
||||||
ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools"
|
|
||||||
ng-change="ctrl.onResourcePoolSelectionChange()"
|
|
||||||
ng-disabled="ctrl.state.isEdit"
|
|
||||||
data-cy="k8sAppCreate-nsSelect"
|
|
||||||
></select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool">
|
|
||||||
<div class="col-sm-12 small text-danger">
|
|
||||||
<pr-icon icon="'alert-triangle'"></pr-icon>
|
|
||||||
This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
|
|
||||||
namespace.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-if="!ctrl.formValues.ResourcePool">
|
|
||||||
<div class="col-sm-12 small text-warning">
|
|
||||||
<pr-icon icon="'alert-triangle'"></pr-icon>
|
|
||||||
You do not have access to any namespace. Contact your administrator to get access to a namespace.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<!-- #region STACK -->
|
<!-- #region STACK -->
|
||||||
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
|
<kube-stack-name
|
||||||
<div class="col-sm-12 small text-muted vertical-center">
|
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
stack-name="ctrl.formValues.StackName"
|
||||||
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
|
set-stack-name="(ctrl.onChangeStackName)"
|
||||||
the application name.
|
stacks="ctrl.stacks"
|
||||||
</div>
|
input-class-name="'col-lg-10 col-sm-9'"
|
||||||
</div>
|
></kube-stack-name>
|
||||||
|
|
||||||
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
|
|
||||||
<label for="stack_name" class="col-sm-3 col-lg-2 control-label text-left">
|
|
||||||
Stack
|
|
||||||
<portainer-tooltip
|
|
||||||
ng-if="!ctrl.isAdmin"
|
|
||||||
message="'The stack field below was previously labelled \'Name\' but, in
|
|
||||||
fact, it\'s always been the stack name (hence the relabelling).'"
|
|
||||||
class-name="'[&>span]:!text-left'"
|
|
||||||
set-html-message="true"
|
|
||||||
>
|
|
||||||
</portainer-tooltip>
|
|
||||||
<portainer-tooltip
|
|
||||||
ng-if="ctrl.isAdmin"
|
|
||||||
message="'The stack field below was previously labelled \'Name\' but, in
|
|
||||||
fact, it\'s always been the stack name (hence the relabelling).<br/>
|
|
||||||
Kubernetes Stacks functionality can be turned off entirely via
|
|
||||||
<a href=\'/#!/settings\' target=\'_blank\'>
|
|
||||||
Kubernetes Settings
|
|
||||||
</a>.'"
|
|
||||||
class-name="'[&>span]:!text-left'"
|
|
||||||
set-html-message="true"
|
|
||||||
>
|
|
||||||
</portainer-tooltip>
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="myStack"
|
|
||||||
ng-model="ctrl.formValues.StackName"
|
|
||||||
name="stack_name"
|
|
||||||
uib-typeahead="stack for stack in ctrl.stacks | filter:$viewValue | limitTo:7"
|
|
||||||
typeahead-min-length="0"
|
|
||||||
data-cy="k8sAppCreate-stackName"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<!-- #region Git repository -->
|
<!-- #region Git repository -->
|
||||||
|
@ -263,89 +183,21 @@
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<!-- #region web editor -->
|
<!-- #region web editor -->
|
||||||
<web-editor-form
|
<edit-yaml-form-section
|
||||||
read-only="ctrl.stack.IsComposeFormat"
|
|
||||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT"
|
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT"
|
||||||
value="ctrl.stackFileContent"
|
values="ctrl.stackFileContent"
|
||||||
yml="true"
|
|
||||||
identifier="kubernetes-deploy-editor"
|
|
||||||
placeholder="Define or paste the content of your manifest file here"
|
|
||||||
on-change="(ctrl.onChangeFileContent)"
|
on-change="(ctrl.onChangeFileContent)"
|
||||||
>
|
is-compose-format="ctrl.stack.IsComposeFormat"
|
||||||
<editor-description>
|
></edit-yaml-form-section>
|
||||||
<div class="flex gap-1" ng-show="ctrl.stack.IsComposeFormat">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
Portainer no longer supports <a href="https://docs.docker.com/compose/compose-file/" target="_blank">docker-compose</a> format manifests for Kubernetes
|
|
||||||
deployments, and we have removed the <a href="https://kompose.io/" target="_blank">Kompose</a>
|
|
||||||
conversion tool which enables this. The reason for this is because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and
|
|
||||||
Exposures (CVEs).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new
|
|
||||||
pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We advise installing your own instance of Kompose in a sandbox environment, performing conversions of your Docker Compose files to Kubernetes manifests and
|
|
||||||
using those manifests to set up applications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span ng-show="!ctrl.stack.IsComposeFormat">
|
|
||||||
<p class="vertical-center">
|
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
|
||||||
This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You can get more information about Kubernetes file format in the
|
|
||||||
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
</editor-description>
|
|
||||||
</web-editor-form>
|
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
<div ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
|
<div ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
|
||||||
<!-- #region NAME FIELD -->
|
<!-- #region NAME FIELD -->
|
||||||
<div class="form-group">
|
<name-form-section
|
||||||
<label for="application_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
values="ctrl.formValues.Name"
|
||||||
<div class="col-sm-8">
|
on-change="(ctrl.onChangeAppName)"
|
||||||
<input
|
is-edit="ctrl.state.isEdit"
|
||||||
type="text"
|
validation-data="{existingNames: ctrl.applicationNames, isEdit: ctrl.state.isEdit, originalName: ctrl.application.Name}"
|
||||||
class="form-control"
|
></name-form-section>
|
||||||
name="application_name"
|
|
||||||
ng-model="ctrl.formValues.Name"
|
|
||||||
ng-change="ctrl.onChangeName()"
|
|
||||||
placeholder="my-app"
|
|
||||||
ng-pattern="/^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/"
|
|
||||||
auto-focus
|
|
||||||
required
|
|
||||||
ng-disabled="ctrl.state.isEdit"
|
|
||||||
data-cy="k8sAppCreate-applicationName"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-show="kubernetesApplicationCreationForm.application_name.$invalid || ctrl.state.alreadyExists">
|
|
||||||
<div class="small">
|
|
||||||
<div class="col-sm-3 col-lg-2"> </div>
|
|
||||||
<div class="col-sm-8" ng-messages="kubernetesApplicationCreationForm.application_name.$error">
|
|
||||||
<p class="text-warning vertical-center" ng-message="required"
|
|
||||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p
|
|
||||||
>
|
|
||||||
<p class="text-warning vertical-center" ng-message="pattern">
|
|
||||||
<pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an
|
|
||||||
alphanumeric character (e.g. 'my-name', or 'abc-123').
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-8" ng-if="ctrl.state.alreadyExists">
|
|
||||||
<p class="text-warning vertical-center">
|
|
||||||
<pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
An application with the same name already exists inside the selected namespace.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<!-- #region IMAGE FIELD -->
|
<!-- #region IMAGE FIELD -->
|
||||||
|
@ -356,7 +208,7 @@
|
||||||
ng-if="ctrl.formValues.ResourcePool"
|
ng-if="ctrl.formValues.ResourcePool"
|
||||||
auto-complete="false"
|
auto-complete="false"
|
||||||
label-class="col-sm-3 col-lg-2"
|
label-class="col-sm-3 col-lg-2"
|
||||||
input-class="col-sm-8"
|
input-class="col-sm-9 col-lg-10"
|
||||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||||
endpoint="ctrl.endpoint"
|
endpoint="ctrl.endpoint"
|
||||||
is-admin="ctrl.isAdmin"
|
is-admin="ctrl.isAdmin"
|
||||||
|
@ -373,29 +225,13 @@
|
||||||
|
|
||||||
<div ng-if="ctrl.formValues.ResourcePool">
|
<div ng-if="ctrl.formValues.ResourcePool">
|
||||||
<!-- #region STACK -->
|
<!-- #region STACK -->
|
||||||
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
|
<kube-stack-name
|
||||||
<div class="col-sm-12 small text-muted vertical-center">
|
ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
stack-name="ctrl.formValues.StackName"
|
||||||
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
|
set-stack-name="(ctrl.onChangeStackName)"
|
||||||
use the application name.
|
stacks="ctrl.stacks"
|
||||||
</div>
|
input-class-name="'col-lg-10 col-sm-9'"
|
||||||
</div>
|
></kube-stack-name>
|
||||||
|
|
||||||
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
|
|
||||||
<label for="stack_name" class="col-sm-3 col-lg-2 control-label text-left">Stack</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="myStack"
|
|
||||||
ng-model="ctrl.formValues.StackName"
|
|
||||||
name="stack_name"
|
|
||||||
uib-typeahead="stack for stack in ctrl.stacks | filter:$viewValue | limitTo:7"
|
|
||||||
typeahead-min-length="0"
|
|
||||||
data-cy="k8sAppCreate-stackName"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<!-- #region ENVIRONMENT VARIABLES -->
|
<!-- #region ENVIRONMENT VARIABLES -->
|
||||||
|
|
|
@ -151,6 +151,7 @@ class KubernetesCreateApplicationController {
|
||||||
this.getAppType = this.getAppType.bind(this);
|
this.getAppType = this.getAppType.bind(this);
|
||||||
this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this);
|
this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this);
|
||||||
this.refreshReactComponent = this.refreshReactComponent.bind(this);
|
this.refreshReactComponent = this.refreshReactComponent.bind(this);
|
||||||
|
this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this);
|
||||||
|
|
||||||
this.$scope.$watch(
|
this.$scope.$watch(
|
||||||
() => this.formValues,
|
() => this.formValues,
|
||||||
|
@ -168,6 +169,15 @@ class KubernetesCreateApplicationController {
|
||||||
this.$timeout(() => {
|
this.$timeout(() => {
|
||||||
this.isTemporaryRefresh = false;
|
this.isTemporaryRefresh = false;
|
||||||
}, 10);
|
}, 10);
|
||||||
|
this.onChangeStackName = this.onChangeStackName.bind(this);
|
||||||
|
this.onChangeAppName = this.onChangeAppName.bind(this);
|
||||||
|
}
|
||||||
|
/* #endregion */
|
||||||
|
|
||||||
|
onChangeStackName(stackName) {
|
||||||
|
return this.$async(async () => {
|
||||||
|
this.formValues.StackName = stackName;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangePlacements(values) {
|
onChangePlacements(values) {
|
||||||
|
@ -254,21 +264,16 @@ class KubernetesCreateApplicationController {
|
||||||
}
|
}
|
||||||
|
|
||||||
imageValidityIsValid() {
|
imageValidityIsValid() {
|
||||||
return this.state.pullImageValidity || this.formValues.ImageModel.Registry.Type !== RegistryTypes.DOCKERHUB;
|
return this.state.pullImageValidity || (this.formValues.registryDetails && this.formValues.registryDetails.Registry.Type !== RegistryTypes.DOCKERHUB);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeName() {
|
onChangeAppName(appName) {
|
||||||
const existingApplication = _.find(this.applications, { Name: this.formValues.Name });
|
return this.$async(async () => {
|
||||||
this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication);
|
this.formValues.Name = appName;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #region AUTO SCALER UI MANAGEMENT */
|
/* #region AUTO SCALER UI MANAGEMENT */
|
||||||
unselectAutoScaler() {
|
|
||||||
if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global) {
|
|
||||||
this.formValues.AutoScaler.isUsed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onAutoScaleChange(values) {
|
onAutoScaleChange(values) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
|
if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
|
||||||
|
@ -295,32 +300,6 @@ class KubernetesCreateApplicationController {
|
||||||
clearConfigMaps() {
|
clearConfigMaps() {
|
||||||
this.formValues.ConfigMaps = [];
|
this.formValues.ConfigMaps = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeConfigMapPath() {
|
|
||||||
this.state.duplicates.configMapPaths.refs = [];
|
|
||||||
|
|
||||||
const paths = _.reduce(
|
|
||||||
this.formValues.ConfigMaps,
|
|
||||||
(result, config) => {
|
|
||||||
const uniqOverridenKeysPath = _.uniq(_.map(config.overridenKeys, 'path'));
|
|
||||||
return _.concat(result, uniqOverridenKeysPath);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const duplicatePaths = KubernetesFormValidationHelper.getDuplicates(paths);
|
|
||||||
|
|
||||||
_.forEach(this.formValues.ConfigMaps, (config, index) => {
|
|
||||||
_.forEach(config.overridenKeys, (overridenKey, keyIndex) => {
|
|
||||||
const findPath = _.find(duplicatePaths, (path) => path === overridenKey.path);
|
|
||||||
if (findPath) {
|
|
||||||
this.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] = findPath;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.state.duplicates.configMapPaths.hasRefs = Object.keys(this.state.duplicates.configMapPaths.refs).length > 0;
|
|
||||||
}
|
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region SECRET UI MANAGEMENT */
|
/* #region SECRET UI MANAGEMENT */
|
||||||
|
@ -421,7 +400,6 @@ class KubernetesCreateApplicationController {
|
||||||
/* #region STATE VALIDATION FUNCTIONS */
|
/* #region STATE VALIDATION FUNCTIONS */
|
||||||
isValid() {
|
isValid() {
|
||||||
return (
|
return (
|
||||||
!this.state.alreadyExists &&
|
|
||||||
!this.state.duplicates.environmentVariables.hasRefs &&
|
!this.state.duplicates.environmentVariables.hasRefs &&
|
||||||
!this.state.duplicates.persistedFolders.hasRefs &&
|
!this.state.duplicates.persistedFolders.hasRefs &&
|
||||||
!this.state.duplicates.configMapPaths.hasRefs &&
|
!this.state.duplicates.configMapPaths.hasRefs &&
|
||||||
|
@ -434,10 +412,6 @@ class KubernetesCreateApplicationController {
|
||||||
return this.storageClasses && this.storageClasses.length > 0;
|
return this.storageClasses && this.storageClasses.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMultipleStorageClassesAvailable() {
|
|
||||||
return this.storageClasses && this.storageClasses.length > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetDeploymentType() {
|
resetDeploymentType() {
|
||||||
this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated;
|
this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated;
|
||||||
}
|
}
|
||||||
|
@ -740,6 +714,7 @@ class KubernetesCreateApplicationController {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
try {
|
try {
|
||||||
this.applications = await this.KubernetesApplicationService.get(namespace);
|
this.applications = await this.KubernetesApplicationService.get(namespace);
|
||||||
|
this.applicationNames = _.map(this.applications, 'Name');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
||||||
}
|
}
|
||||||
|
@ -796,7 +771,6 @@ class KubernetesCreateApplicationController {
|
||||||
this.refreshIngresses(namespace),
|
this.refreshIngresses(namespace),
|
||||||
this.refreshVolumes(namespace),
|
this.refreshVolumes(namespace),
|
||||||
]);
|
]);
|
||||||
this.onChangeName();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -806,13 +780,13 @@ class KubernetesCreateApplicationController {
|
||||||
this.resetPersistedFolders();
|
this.resetPersistedFolders();
|
||||||
}
|
}
|
||||||
|
|
||||||
onResourcePoolSelectionChange() {
|
onChangeNamespaceName(namespaceName) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
const namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.formValues.ResourcePool.Namespace.Name);
|
this.formValues.ResourcePool.Namespace.Name = namespaceName;
|
||||||
const namespace = this.formValues.ResourcePool.Namespace.Name;
|
const namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespaceName);
|
||||||
this.updateNamespaceLimits(namespaceWithQuota);
|
this.updateNamespaceLimits(namespaceWithQuota);
|
||||||
this.updateSliders(namespaceWithQuota);
|
this.updateSliders(namespaceWithQuota);
|
||||||
await this.refreshNamespaceData(namespace);
|
await this.refreshNamespaceData(namespaceName);
|
||||||
this.resetFormValues();
|
this.resetFormValues();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -181,6 +181,7 @@ export const ngModule = angular
|
||||||
'isClearable',
|
'isClearable',
|
||||||
'components',
|
'components',
|
||||||
'isLoading',
|
'isLoading',
|
||||||
|
'noOptionsMessage',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { IFormController, IComponentOptions, IModule } from 'angular';
|
import { IFormController, IComponentOptions, IModule } from 'angular';
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
import { SchemaOf } from 'yup';
|
import { SchemaOf, object } from 'yup';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { ComponentType } from 'react';
|
import { ComponentType } from 'react';
|
||||||
|
|
||||||
|
@ -40,7 +40,8 @@ export function withFormValidation<TProps, TValue, TData = never>(
|
||||||
Component: ComponentType<WithFormFieldProps<TProps, TValue>>,
|
Component: ComponentType<WithFormFieldProps<TProps, TValue>>,
|
||||||
componentName: string,
|
componentName: string,
|
||||||
propNames: PropNames<TProps>[],
|
propNames: PropNames<TProps>[],
|
||||||
schemaBuilder: (validationData?: TData) => SchemaOf<TValue>
|
schemaBuilder: (validationData?: TData) => SchemaOf<TValue>,
|
||||||
|
isPrimitive = false
|
||||||
) {
|
) {
|
||||||
const reactComponentName = `react${_.upperFirst(componentName)}`;
|
const reactComponentName = `react${_.upperFirst(componentName)}`;
|
||||||
|
|
||||||
|
@ -54,7 +55,8 @@ export function withFormValidation<TProps, TValue, TData = never>(
|
||||||
createFormValidationComponent(
|
createFormValidationComponent(
|
||||||
reactComponentName,
|
reactComponentName,
|
||||||
propNames,
|
propNames,
|
||||||
schemaBuilder
|
schemaBuilder,
|
||||||
|
isPrimitive
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -62,7 +64,8 @@ export function withFormValidation<TProps, TValue, TData = never>(
|
||||||
export function createFormValidationComponent<TFormModel, TData = never>(
|
export function createFormValidationComponent<TFormModel, TData = never>(
|
||||||
componentName: string,
|
componentName: string,
|
||||||
propNames: Array<string>,
|
propNames: Array<string>,
|
||||||
schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel>
|
schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel>,
|
||||||
|
isPrimitive = false
|
||||||
): IComponentOptions {
|
): IComponentOptions {
|
||||||
const kebabName = _.kebabCase(componentName);
|
const kebabName = _.kebabCase(componentName);
|
||||||
const propsWithErrors = [...propNames, 'errors', 'values'];
|
const propsWithErrors = [...propNames, 'errors', 'values'];
|
||||||
|
@ -76,7 +79,7 @@ export function createFormValidationComponent<TFormModel, TData = never>(
|
||||||
on-change="($ctrl.handleChange)"
|
on-change="($ctrl.handleChange)"
|
||||||
></${kebabName}>
|
></${kebabName}>
|
||||||
</ng-form>`,
|
</ng-form>`,
|
||||||
controller: createFormValidatorController(schemaBuilder),
|
controller: createFormValidatorController(schemaBuilder, isPrimitive),
|
||||||
bindings: Object.fromEntries(
|
bindings: Object.fromEntries(
|
||||||
[...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<'])
|
[...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<'])
|
||||||
),
|
),
|
||||||
|
@ -84,10 +87,11 @@ export function createFormValidationComponent<TFormModel, TData = never>(
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFormValidatorController<TFormModel, TData = never>(
|
function createFormValidatorController<TFormModel, TData = never>(
|
||||||
schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel>
|
schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel>,
|
||||||
|
isPrimitive = false
|
||||||
) {
|
) {
|
||||||
return class FormValidatorController {
|
return class FormValidatorController {
|
||||||
errors?: FormikErrors<TFormModel> = {};
|
errors?: FormikErrors<TFormModel>;
|
||||||
|
|
||||||
$async: <T>(fn: () => Promise<T>) => Promise<T>;
|
$async: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||||
|
|
||||||
|
@ -118,12 +122,17 @@ function createFormValidatorController<TFormModel, TData = never>(
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
this.form?.$setValidity('form', true, this.form);
|
this.form?.$setValidity('form', true, this.form);
|
||||||
|
|
||||||
this.errors = await validateForm<TFormModel>(
|
const schema = schemaBuilder(this.validationData);
|
||||||
() => schemaBuilder(this.validationData),
|
this.errors = undefined;
|
||||||
value
|
const errors = await (isPrimitive
|
||||||
);
|
? validateForm<{ value: TFormModel }>(
|
||||||
|
() => object({ value: schema }),
|
||||||
|
{ value }
|
||||||
|
).then((r) => r?.value)
|
||||||
|
: validateForm<TFormModel>(() => schema, value));
|
||||||
|
|
||||||
if (this.errors && Object.keys(this.errors).length > 0) {
|
if (errors && Object.keys(errors).length > 0) {
|
||||||
|
this.errors = errors as FormikErrors<TFormModel> | undefined;
|
||||||
this.form?.$setValidity('form', false, this.form);
|
this.form?.$setValidity('form', false, this.form);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { buildConfirmButton } from './modals/utils';
|
||||||
const otherEditorConfig = {
|
const otherEditorConfig = {
|
||||||
tooltip: (
|
tooltip: (
|
||||||
<>
|
<>
|
||||||
<div>Ctrl+F - Start searching</div>
|
<div>CtrlF - Start searching</div>
|
||||||
<div>Ctrl+G - Find next</div>
|
<div>Ctrl+G - Find next</div>
|
||||||
<div>Ctrl+Shift+G - Find previous</div>
|
<div>Ctrl+Shift+G - Find previous</div>
|
||||||
<div>Ctrl+Shift+F - Replace</div>
|
<div>Ctrl+Shift+F - Replace</div>
|
||||||
|
@ -29,7 +29,7 @@ const otherEditorConfig = {
|
||||||
searchCmdLabel: 'Ctrl+F for search',
|
searchCmdLabel: 'Ctrl+F for search',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const editorConfig = {
|
export const editorConfig = {
|
||||||
mac: {
|
mac: {
|
||||||
tooltip: (
|
tooltip: (
|
||||||
<>
|
<>
|
||||||
|
@ -59,6 +59,7 @@ interface Props {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
yaml?: boolean;
|
yaml?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
titleContent?: React.ReactNode;
|
||||||
hideTitle?: boolean;
|
hideTitle?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
|
@ -69,6 +70,7 @@ export function WebEditorForm({
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
value,
|
value,
|
||||||
|
titleContent = '',
|
||||||
hideTitle,
|
hideTitle,
|
||||||
readonly,
|
readonly,
|
||||||
yaml,
|
yaml,
|
||||||
|
@ -80,16 +82,11 @@ export function WebEditorForm({
|
||||||
<div>
|
<div>
|
||||||
<div className="web-editor overflow-x-hidden">
|
<div className="web-editor overflow-x-hidden">
|
||||||
{!hideTitle && (
|
{!hideTitle && (
|
||||||
<FormSectionTitle htmlFor={id}>
|
<>
|
||||||
Web editor
|
<DefaultTitle id={id} />
|
||||||
<div className="text-muted small vertical-center ml-auto">
|
{titleContent ?? null}
|
||||||
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
|
</>
|
||||||
|
|
||||||
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
|
|
||||||
</div>
|
|
||||||
</FormSectionTitle>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{children && (
|
{children && (
|
||||||
<div className="form-group text-muted small">
|
<div className="form-group text-muted small">
|
||||||
<div className="col-sm-12 col-lg-12">{children}</div>
|
<div className="col-sm-12 col-lg-12">{children}</div>
|
||||||
|
@ -116,6 +113,19 @@ export function WebEditorForm({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DefaultTitle({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<FormSectionTitle htmlFor={id}>
|
||||||
|
Web editor
|
||||||
|
<div className="text-muted small vertical-center ml-auto">
|
||||||
|
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
|
||||||
|
|
||||||
|
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
|
||||||
|
</div>
|
||||||
|
</FormSectionTitle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function usePreventExit(
|
export function usePreventExit(
|
||||||
initialValue: string,
|
initialValue: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { Tooltip } from '@@/Tip/Tooltip';
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
|
||||||
import { FormError } from '../FormError';
|
import { FormError } from '../FormError';
|
||||||
|
|
||||||
|
@ -17,6 +18,8 @@ export interface Props {
|
||||||
errors?: ReactNode;
|
errors?: ReactNode;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isLoading?: boolean; // whether to show an inline loader, instead of the children
|
||||||
|
loadingText?: ReactNode; // text to show when isLoading is true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormControl({
|
export function FormControl({
|
||||||
|
@ -29,6 +32,8 @@ export function FormControl({
|
||||||
className,
|
className,
|
||||||
required = false,
|
required = false,
|
||||||
setTooltipHtmlMessage,
|
setTooltipHtmlMessage,
|
||||||
|
isLoading = false,
|
||||||
|
loadingText = 'Loading...',
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -52,7 +57,8 @@ export function FormControl({
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className={sizeClassChildren(size)}>
|
<div className={sizeClassChildren(size)}>
|
||||||
{children}
|
{isLoading && <InlineLoader>{loadingText}</InlineLoader>}
|
||||||
|
{!isLoading && children}
|
||||||
{errors && <FormError>{errors}</FormError>}
|
{errors && <FormError>{errors}</FormError>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -28,6 +28,7 @@ interface SharedProps extends AutomationTestingProps {
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
bindToBody?: boolean;
|
bindToBody?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
noOptionsMessage?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MultiProps<TValue> extends SharedProps {
|
interface MultiProps<TValue> extends SharedProps {
|
||||||
|
@ -85,6 +86,7 @@ export function SingleSelect<TValue = string>({
|
||||||
bindToBody,
|
bindToBody,
|
||||||
components,
|
components,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
noOptionsMessage,
|
||||||
}: SingleProps<TValue>) {
|
}: SingleProps<TValue>) {
|
||||||
const selectedValue =
|
const selectedValue =
|
||||||
value || (typeof value === 'number' && value === 0)
|
value || (typeof value === 'number' && value === 0)
|
||||||
|
@ -108,6 +110,7 @@ export function SingleSelect<TValue = string>({
|
||||||
menuPortalTarget={bindToBody ? document.body : undefined}
|
menuPortalTarget={bindToBody ? document.body : undefined}
|
||||||
components={components}
|
components={components}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
noOptionsMessage={noOptionsMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -148,6 +151,7 @@ export function MultiSelect<TValue = string>({
|
||||||
bindToBody,
|
bindToBody,
|
||||||
components,
|
components,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
noOptionsMessage,
|
||||||
}: Omit<MultiProps<TValue>, 'isMulti'>) {
|
}: Omit<MultiProps<TValue>, 'isMulti'>) {
|
||||||
const selectedOptions = findSelectedOptions(options, value);
|
const selectedOptions = findSelectedOptions(options, value);
|
||||||
return (
|
return (
|
||||||
|
@ -169,6 +173,7 @@ export function MultiSelect<TValue = string>({
|
||||||
menuPortalTarget={bindToBody ? document.body : undefined}
|
menuPortalTarget={bindToBody ? document.body : undefined}
|
||||||
components={components}
|
components={components}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
noOptionsMessage={noOptionsMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,31 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
import { InsightsBox } from '@@/InsightsBox';
|
import { InsightsBox } from '@@/InsightsBox';
|
||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
import { Tooltip } from '@@/Tip/Tooltip';
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stackName: string;
|
stackName: string;
|
||||||
setStackName: (name: string) => void;
|
setStackName: (name: string) => void;
|
||||||
isAdmin?: boolean;
|
stacks?: string[];
|
||||||
|
inputClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StackName({ stackName, setStackName, isAdmin = false }: Props) {
|
export function StackName({
|
||||||
|
stackName,
|
||||||
|
setStackName,
|
||||||
|
stacks = [],
|
||||||
|
inputClassName,
|
||||||
|
}: Props) {
|
||||||
|
const { isAdmin } = useCurrentUser();
|
||||||
|
const stackResults = useMemo(
|
||||||
|
() => stacks.filter((stack) => stack.includes(stackName ?? '')),
|
||||||
|
[stacks, stackName]
|
||||||
|
);
|
||||||
const tooltip = (
|
const tooltip = (
|
||||||
<>
|
<>
|
||||||
You may specify a stack name to label resources that you want to group.
|
You may specify a stack name to label resources that you want to group.
|
||||||
|
@ -68,14 +84,16 @@ export function StackName({ stackName, setStackName, isAdmin = false }: Props) {
|
||||||
Stack
|
Stack
|
||||||
<Tooltip message={tooltip} setHtmlMessage />
|
<Tooltip message={tooltip} setHtmlMessage />
|
||||||
</label>
|
</label>
|
||||||
<div className="col-sm-8">
|
<div className={inputClassName || 'col-sm-8'}>
|
||||||
<input
|
<AutocompleteSelect
|
||||||
type="text"
|
searchResults={stackResults?.map((result) => ({
|
||||||
className="form-control"
|
value: result,
|
||||||
defaultValue={stackName}
|
label: result,
|
||||||
onChange={(e) => setStackName(e.target.value)}
|
}))}
|
||||||
id="stack_name"
|
value={stackName ?? ''}
|
||||||
placeholder="myStack"
|
onChange={setStackName}
|
||||||
|
placeholder="e.g. myStack"
|
||||||
|
inputId="stack_name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useEnvironmentDeploymentOptions } from '@/react/portainer/environments/queries/useEnvironment';
|
||||||
|
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
type StackFileContent = string;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
values: StackFileContent;
|
||||||
|
onChange: (values: StackFileContent) => void;
|
||||||
|
isComposeFormat?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditYamlFormSection({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
isComposeFormat,
|
||||||
|
}: Props) {
|
||||||
|
// check if the user is allowed to edit the yaml
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const { data: deploymentOptions } =
|
||||||
|
useEnvironmentDeploymentOptions(environmentId);
|
||||||
|
const roleHasAuth = useAuthorizations('K8sYAMLW');
|
||||||
|
const isAllowedToEdit = roleHasAuth && !deploymentOptions?.hideWebEditor;
|
||||||
|
const formId = 'kubernetes-deploy-editor';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<WebEditorForm
|
||||||
|
value={values}
|
||||||
|
readonly={!isAllowedToEdit}
|
||||||
|
titleContent={<TitleContent isComposeFormat={isComposeFormat} />}
|
||||||
|
onChange={(values) => onChange(values)}
|
||||||
|
id={formId}
|
||||||
|
placeholder="Define or paste the content of your manifest file here"
|
||||||
|
yaml
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleContent({ isComposeFormat }: { isComposeFormat?: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isComposeFormat && (
|
||||||
|
<TextTip color="orange">
|
||||||
|
<p>
|
||||||
|
Portainer no longer supports{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.docker.com/compose/compose-file/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
docker-compose
|
||||||
|
</a>{' '}
|
||||||
|
format manifests for Kubernetes deployments, and we have removed the{' '}
|
||||||
|
<a href="https://kompose.io/" target="_blank" rel="noreferrer">
|
||||||
|
Kompose
|
||||||
|
</a>{' '}
|
||||||
|
conversion tool which enables this. The reason for this is because
|
||||||
|
Kompose now poses a security risk, since it has a number of Common
|
||||||
|
Vulnerabilities and Exposures (CVEs).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Unfortunately, while the Kompose project has a maintainer and is
|
||||||
|
part of the CNCF, it is not being actively maintained. Releases are
|
||||||
|
very infrequent and new pull requests to the project (including ones
|
||||||
|
we've submitted) are taking months to be merged, with new CVEs
|
||||||
|
arising in the meantime.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We advise installing your own instance of Kompose in a sandbox
|
||||||
|
environment, performing conversions of your Docker Compose files to
|
||||||
|
Kubernetes manifests and using those manifests to set up
|
||||||
|
applications.
|
||||||
|
</p>
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
{!isComposeFormat && (
|
||||||
|
<TextTip color="blue">
|
||||||
|
<p>
|
||||||
|
This feature allows you to deploy any kind of Kubernetes resource in
|
||||||
|
this environment (Deployment, Secret, ConfigMap...).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can get more information about Kubernetes file format in the{' '}
|
||||||
|
<a
|
||||||
|
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
values: string;
|
||||||
|
errors: FormikErrors<string>;
|
||||||
|
isEdit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NameFormSection({
|
||||||
|
onChange,
|
||||||
|
values: appName,
|
||||||
|
errors,
|
||||||
|
isEdit,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
label="Name"
|
||||||
|
inputId="application_name"
|
||||||
|
errors={errors}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={appName ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. my-app"
|
||||||
|
disabled={isEdit}
|
||||||
|
id="application_name"
|
||||||
|
data-cy="k8sAppCreate-applicationName"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { NameFormSection } from './NameFormSection';
|
||||||
|
export { appNameValidation } from './nameValidation';
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { SchemaOf, string as yupString } from 'yup';
|
||||||
|
|
||||||
|
type ValidationData = {
|
||||||
|
existingNames: string[];
|
||||||
|
isEdit: boolean;
|
||||||
|
originalName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function appNameValidation(
|
||||||
|
validationData?: ValidationData
|
||||||
|
): SchemaOf<string> {
|
||||||
|
return yupString()
|
||||||
|
.required('This field is required.')
|
||||||
|
.test(
|
||||||
|
'is-unique',
|
||||||
|
'An application with the same name already exists inside the selected namespace.',
|
||||||
|
(appName) => {
|
||||||
|
if (!validationData || !appName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// if creating, check if the name is unique
|
||||||
|
if (!validationData.isEdit) {
|
||||||
|
return !validationData.existingNames.includes(appName);
|
||||||
|
}
|
||||||
|
// if editing, the original name will be in the list of existing names
|
||||||
|
// remove it before checking if the name is unique
|
||||||
|
const updatedExistingNames = validationData.existingNames.filter(
|
||||||
|
(name) => name !== validationData.originalName
|
||||||
|
);
|
||||||
|
return !updatedExistingNames.includes(appName);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'is-valid',
|
||||||
|
"This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').",
|
||||||
|
(appName) => {
|
||||||
|
if (!appName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/g.test(appName);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
values: string;
|
||||||
|
errors: FormikErrors<string>;
|
||||||
|
isEdit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NamespaceSelector({
|
||||||
|
values: value,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
isEdit,
|
||||||
|
}: Props) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const { data: namespaces, ...namespacesQuery } =
|
||||||
|
useNamespacesQuery(environmentId);
|
||||||
|
const namespaceNames = Object.entries(namespaces ?? {})
|
||||||
|
.filter(([, ns]) => !ns.IsSystem)
|
||||||
|
.map(([nsName]) => ({
|
||||||
|
label: nsName,
|
||||||
|
value: nsName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
label="Namespace"
|
||||||
|
inputId="namespace-selector"
|
||||||
|
isLoading={namespacesQuery.isLoading}
|
||||||
|
errors={errors}
|
||||||
|
>
|
||||||
|
{namespaceNames.length > 0 && (
|
||||||
|
<PortainerSelect
|
||||||
|
value={value}
|
||||||
|
options={namespaceNames}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={isEdit}
|
||||||
|
noOptionsMessage={() => 'No namespaces found'}
|
||||||
|
placeholder="No namespaces found" // will only show when there are no options
|
||||||
|
inputId="namespace-selector"
|
||||||
|
data-cy="k8sAppCreate-nsSelect"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { NamespaceSelector } from './NamespaceSelector';
|
||||||
|
export { namespaceSelectorValidation } from './namespaceSelectorValidation';
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
|
type ValidationData = {
|
||||||
|
hasQuota: boolean;
|
||||||
|
isResourceQuotaCapacityExceeded: boolean;
|
||||||
|
namespaceOptionCount: number;
|
||||||
|
isAdmin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyValue =
|
||||||
|
'You do not have access to any namespace. Contact your administrator to get access to a namespace.';
|
||||||
|
|
||||||
|
export function namespaceSelectorValidation(
|
||||||
|
validationData?: ValidationData
|
||||||
|
): SchemaOf<string> {
|
||||||
|
const {
|
||||||
|
hasQuota,
|
||||||
|
isResourceQuotaCapacityExceeded,
|
||||||
|
namespaceOptionCount,
|
||||||
|
isAdmin,
|
||||||
|
} = validationData || {};
|
||||||
|
return string()
|
||||||
|
.required(emptyValue)
|
||||||
|
.typeError(emptyValue)
|
||||||
|
.test(
|
||||||
|
'resourceQuotaCapacityExceeded',
|
||||||
|
`This namespace has exhausted its resource capacity and you will not be able to deploy the application.${
|
||||||
|
isAdmin
|
||||||
|
? ''
|
||||||
|
: ' Contact your administrator to expand the capacity of the namespace.'
|
||||||
|
}`,
|
||||||
|
() => {
|
||||||
|
const hasQuotaExceeded = hasQuota && isResourceQuotaCapacityExceeded;
|
||||||
|
return !hasQuotaExceeded;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.test('namespaceOptionCount', emptyValue, () => !!namespaceOptionCount);
|
||||||
|
}
|
|
@ -54,7 +54,7 @@ export function YAMLInspector({ identifier, data, hideMessage }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanYamlUnwantedFields(yml: string) {
|
export function cleanYamlUnwantedFields(yml: string) {
|
||||||
try {
|
try {
|
||||||
const ymls = yml.split('---');
|
const ymls = yml.split('---');
|
||||||
const cleanYmls = ymls.map((yml) => {
|
const cleanYmls = ymls.map((yml) => {
|
||||||
|
|
|
@ -29,6 +29,6 @@ async function getRegistry(registryId: Registry['Id'], environmentId: number) {
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw parseAxiosError(err as Error, 'XXXUnable to retrieve registry');
|
throw parseAxiosError(err as Error, 'Unable to retrieve registry');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue