mirror of https://github.com/portainer/portainer
refactor(app): migrate configmap and secret form sections [EE-6233] (#10528)
* refactor(app): migrate configmap and secret form sections [EE-6233]pull/8699/merge
parent
391b85da41
commit
7a2412b1be
|
@ -14,6 +14,7 @@ class KubernetesConfigurationConverter {
|
||||||
_.forEach(secret.Data, (entry) => {
|
_.forEach(secret.Data, (entry) => {
|
||||||
res.Data[entry.Key] = entry.Value;
|
res.Data[entry.Key] = entry.Value;
|
||||||
});
|
});
|
||||||
|
res.data = res.Data;
|
||||||
res.ConfigurationOwner = secret.ConfigurationOwner;
|
res.ConfigurationOwner = secret.ConfigurationOwner;
|
||||||
res.IsRegistrySecret = secret.IsRegistrySecret;
|
res.IsRegistrySecret = secret.IsRegistrySecret;
|
||||||
res.SecretType = secret.SecretType;
|
res.SecretType = secret.SecretType;
|
||||||
|
@ -34,6 +35,7 @@ class KubernetesConfigurationConverter {
|
||||||
_.forEach(configMap.Data, (entry) => {
|
_.forEach(configMap.Data, (entry) => {
|
||||||
res.Data[entry.Key] = entry.Value;
|
res.Data[entry.Key] = entry.Value;
|
||||||
});
|
});
|
||||||
|
res.data = res.Data;
|
||||||
res.ConfigurationOwner = configMap.ConfigurationOwner;
|
res.ConfigurationOwner = configMap.ConfigurationOwner;
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,6 +90,7 @@ class KubernetesSecretConverter {
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
|
res.data = res.Data;
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
KubernetesApplicationAutoScalerFormValue,
|
KubernetesApplicationAutoScalerFormValue,
|
||||||
KubernetesApplicationConfigurationFormValue,
|
KubernetesApplicationConfigurationFormValue,
|
||||||
KubernetesApplicationConfigurationFormValueOverridenKey,
|
KubernetesApplicationConfigurationFormValueOverridenKey,
|
||||||
KubernetesApplicationConfigurationFormValueOverridenKeyTypes,
|
|
||||||
KubernetesApplicationEnvironmentVariableFormValue,
|
KubernetesApplicationEnvironmentVariableFormValue,
|
||||||
KubernetesApplicationPersistedFolderFormValue,
|
KubernetesApplicationPersistedFolderFormValue,
|
||||||
KubernetesApplicationPlacementFormValue,
|
KubernetesApplicationPlacementFormValue,
|
||||||
|
@ -169,21 +168,25 @@ class KubernetesApplicationHelper {
|
||||||
const overrideThreshold = max - _.max(_.map(keys, 'VolCount'));
|
const overrideThreshold = max - _.max(_.map(keys, 'VolCount'));
|
||||||
const res = _.map(new Array(max), () => new KubernetesApplicationConfigurationFormValue());
|
const res = _.map(new Array(max), () => new KubernetesApplicationConfigurationFormValue());
|
||||||
_.forEach(res, (item, index) => {
|
_.forEach(res, (item, index) => {
|
||||||
item.SelectedConfiguration = cfg;
|
item.selectedConfiguration = cfg;
|
||||||
|
// workaround to load configurations in the app in the select inputs
|
||||||
|
// this should be removed when the edit parent view is migrated to react
|
||||||
|
item.selectedConfiguration.metadata = {};
|
||||||
|
item.selectedConfiguration.metadata.name = cfg.Name;
|
||||||
const overriden = index >= overrideThreshold;
|
const overriden = index >= overrideThreshold;
|
||||||
if (overriden) {
|
if (overriden) {
|
||||||
item.Overriden = true;
|
item.overriden = true;
|
||||||
item.OverridenKeys = _.map(keys, (k) => {
|
item.overridenKeys = _.map(keys, (k) => {
|
||||||
const fvKey = new KubernetesApplicationConfigurationFormValueOverridenKey();
|
const fvKey = new KubernetesApplicationConfigurationFormValueOverridenKey();
|
||||||
fvKey.Key = k.Key;
|
fvKey.key = k.Key;
|
||||||
if (!k.Count) {
|
if (!k.Count) {
|
||||||
// !k.Count indicates k.Key is new added to the configuration and has not been loaded to the application yet
|
// !k.Count indicates k.key is new added to the configuration and has not been loaded to the application yet
|
||||||
fvKey.Type = KubernetesApplicationConfigurationFormValueOverridenKeyTypes.NONE;
|
fvKey.type = 'NONE';
|
||||||
} else if (index < k.EnvCount) {
|
} else if (index < k.EnvCount) {
|
||||||
fvKey.Type = KubernetesApplicationConfigurationFormValueOverridenKeyTypes.ENVIRONMENT;
|
fvKey.type = 'ENVIRONMENT';
|
||||||
} else {
|
} else {
|
||||||
fvKey.Type = KubernetesApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM;
|
fvKey.type = 'FILESYSTEM';
|
||||||
fvKey.Path = k.Sum[index].rootMountPath;
|
fvKey.path = k.Sum[index].rootMountPath;
|
||||||
}
|
}
|
||||||
return fvKey;
|
return fvKey;
|
||||||
});
|
});
|
||||||
|
@ -201,46 +204,46 @@ class KubernetesApplicationHelper {
|
||||||
let finalMounts = [];
|
let finalMounts = [];
|
||||||
|
|
||||||
_.forEach(configurations, (config) => {
|
_.forEach(configurations, (config) => {
|
||||||
const isBasic = config.SelectedConfiguration.Kind === KubernetesConfigurationKinds.CONFIGMAP;
|
const isBasic = config.selectedConfiguration.kind === 'ConfigMap';
|
||||||
|
|
||||||
if (!config.Overriden) {
|
if (!config.overriden) {
|
||||||
const envKeys = _.keys(config.SelectedConfiguration.Data);
|
const envKeys = _.keys(config.selectedConfiguration.data);
|
||||||
_.forEach(envKeys, (item) => {
|
_.forEach(envKeys, (item) => {
|
||||||
const res = isBasic ? new KubernetesApplicationEnvConfigMapPayload() : new KubernetesApplicationEnvSecretPayload();
|
const res = isBasic ? new KubernetesApplicationEnvConfigMapPayload() : new KubernetesApplicationEnvSecretPayload();
|
||||||
res.name = item;
|
res.name = item;
|
||||||
if (isBasic) {
|
if (isBasic) {
|
||||||
res.valueFrom.configMapKeyRef.name = config.SelectedConfiguration.Name;
|
res.valueFrom.configMapKeyRef.name = config.selectedConfiguration.metadata.name;
|
||||||
res.valueFrom.configMapKeyRef.key = item;
|
res.valueFrom.configMapKeyRef.key = item;
|
||||||
} else {
|
} else {
|
||||||
res.valueFrom.secretKeyRef.name = config.SelectedConfiguration.Name;
|
res.valueFrom.secretKeyRef.name = config.selectedConfiguration.metadata.name;
|
||||||
res.valueFrom.secretKeyRef.key = item;
|
res.valueFrom.secretKeyRef.key = item;
|
||||||
}
|
}
|
||||||
finalEnv.push(res);
|
finalEnv.push(res);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const envKeys = _.filter(config.OverridenKeys, (item) => item.Type === KubernetesApplicationConfigurationFormValueOverridenKeyTypes.ENVIRONMENT);
|
const envKeys = _.filter(config.overridenKeys, (item) => item.type === 'ENVIRONMENT');
|
||||||
_.forEach(envKeys, (item) => {
|
_.forEach(envKeys, (item) => {
|
||||||
const res = isBasic ? new KubernetesApplicationEnvConfigMapPayload() : new KubernetesApplicationEnvSecretPayload();
|
const res = isBasic ? new KubernetesApplicationEnvConfigMapPayload() : new KubernetesApplicationEnvSecretPayload();
|
||||||
res.name = item.Key;
|
res.name = item.key;
|
||||||
if (isBasic) {
|
if (isBasic) {
|
||||||
res.valueFrom.configMapKeyRef.name = config.SelectedConfiguration.Name;
|
res.valueFrom.configMapKeyRef.name = config.selectedConfiguration.metadata.name;
|
||||||
res.valueFrom.configMapKeyRef.key = item.Key;
|
res.valueFrom.configMapKeyRef.key = item.key;
|
||||||
} else {
|
} else {
|
||||||
res.valueFrom.secretKeyRef.name = config.SelectedConfiguration.Name;
|
res.valueFrom.secretKeyRef.name = config.selectedConfiguration.metadata.name;
|
||||||
res.valueFrom.secretKeyRef.key = item.Key;
|
res.valueFrom.secretKeyRef.key = item.key;
|
||||||
}
|
}
|
||||||
finalEnv.push(res);
|
finalEnv.push(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const volKeys = _.filter(config.OverridenKeys, (item) => item.Type === KubernetesApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM);
|
const volKeys = _.filter(config.overridenKeys, (item) => item.type === 'FILESYSTEM');
|
||||||
const groupedVolKeys = _.groupBy(volKeys, 'Path');
|
const groupedVolKeys = _.groupBy(volKeys, 'path');
|
||||||
_.forEach(groupedVolKeys, (items, path) => {
|
_.forEach(groupedVolKeys, (items, path) => {
|
||||||
const volumeName = KubernetesVolumeHelper.generatedApplicationConfigVolumeName(app.Name);
|
const volumeName = KubernetesVolumeHelper.generatedApplicationConfigVolumeName(app.Name);
|
||||||
const configurationName = config.SelectedConfiguration.Name;
|
const configurationName = config.selectedConfiguration.metadata.name;
|
||||||
const itemsMap = _.map(items, (item) => {
|
const itemsMap = _.map(items, (item) => {
|
||||||
const entry = new KubernetesApplicationVolumeEntryPayload();
|
const entry = new KubernetesApplicationVolumeEntryPayload();
|
||||||
entry.key = item.Key;
|
entry.key = item.key;
|
||||||
entry.path = item.Key;
|
entry.path = item.key;
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -30,19 +30,13 @@ export function KubernetesApplicationFormValues() {
|
||||||
this.OriginalIngresses = undefined;
|
this.OriginalIngresses = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KubernetesApplicationConfigurationFormValueOverridenKeyTypes = Object.freeze({
|
|
||||||
NONE: 0,
|
|
||||||
ENVIRONMENT: 1,
|
|
||||||
FILESYSTEM: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KubernetesApplicationConfigurationFormValueOverridenKey Model
|
* KubernetesApplicationConfigurationFormValueOverridenKey Model
|
||||||
*/
|
*/
|
||||||
const _KubernetesApplicationConfigurationFormValueOverridenKey = Object.freeze({
|
const _KubernetesApplicationConfigurationFormValueOverridenKey = Object.freeze({
|
||||||
Key: '',
|
key: '',
|
||||||
Path: '',
|
path: '',
|
||||||
Type: KubernetesApplicationConfigurationFormValueOverridenKeyTypes.ENVIRONMENT,
|
type: 'ENVIRONMENT',
|
||||||
});
|
});
|
||||||
|
|
||||||
export class KubernetesApplicationConfigurationFormValueOverridenKey {
|
export class KubernetesApplicationConfigurationFormValueOverridenKey {
|
||||||
|
@ -55,9 +49,9 @@ export class KubernetesApplicationConfigurationFormValueOverridenKey {
|
||||||
* KubernetesApplicationConfigurationFormValue Model
|
* KubernetesApplicationConfigurationFormValue Model
|
||||||
*/
|
*/
|
||||||
const _KubernetesApplicationConfigurationFormValue = Object.freeze({
|
const _KubernetesApplicationConfigurationFormValue = Object.freeze({
|
||||||
SelectedConfiguration: undefined,
|
selectedConfiguration: undefined,
|
||||||
Overriden: false,
|
overriden: false,
|
||||||
OverridenKeys: [], // KubernetesApplicationConfigurationFormValueOverridenKey list
|
overridenKeys: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export class KubernetesApplicationConfigurationFormValue {
|
export class KubernetesApplicationConfigurationFormValue {
|
||||||
|
|
|
@ -25,6 +25,9 @@ import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/Lis
|
||||||
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
|
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
|
||||||
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
|
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
|
||||||
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/ApplicationForm/kubeEnvVarValidationSchema';
|
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/ApplicationForm/kubeEnvVarValidationSchema';
|
||||||
|
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';
|
||||||
|
|
||||||
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
|
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||||
|
|
||||||
|
@ -186,3 +189,19 @@ withFormValidation(
|
||||||
// use kubeEnvVarValidationSchema instead of envVarValidation to add a regex matches rule
|
// use kubeEnvVarValidationSchema instead of envVarValidation to add a regex matches rule
|
||||||
kubeEnvVarValidationSchema
|
kubeEnvVarValidationSchema
|
||||||
);
|
);
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
withUIRouter(withCurrentUser(withReactQuery(ConfigMapsFormSection))),
|
||||||
|
'configMapsFormSection',
|
||||||
|
['values', 'onChange', 'namespace'],
|
||||||
|
configurationsValidationSchema
|
||||||
|
);
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
withUIRouter(withCurrentUser(withReactQuery(SecretsFormSection))),
|
||||||
|
'secretsFormSection',
|
||||||
|
['values', 'onChange', 'namespace'],
|
||||||
|
configurationsValidationSchema
|
||||||
|
);
|
||||||
|
|
|
@ -381,7 +381,7 @@
|
||||||
<!-- #region ENVIRONMENT VARIABLES -->
|
<!-- #region ENVIRONMENT VARIABLES -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12 vertical-center">
|
<div class="col-sm-12 vertical-center">
|
||||||
<label class="control-label !pt-0 text-left">Environment variables</label>
|
<label class="control-label !pt-0 text-left !text-sm">Environment variables</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-11 col-lg-10 mt-2">
|
<div class="col-sm-11 col-lg-10 mt-2">
|
||||||
<kube-environment-variables-fieldset
|
<kube-environment-variables-fieldset
|
||||||
|
@ -394,313 +394,26 @@
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<!-- #region CONFIGMAPS -->
|
<!-- #region CONFIGMAPS -->
|
||||||
<div class="form-group">
|
<config-maps-form-section
|
||||||
<div class="col-sm-12 vertical-center">
|
values="ctrl.formValues.ConfigMaps"
|
||||||
<label class="control-label !pt-0 text-left">ConfigMaps</label>
|
on-change="(ctrl.onConfigMapsChange)"
|
||||||
</div>
|
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||||
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length">
|
validation-data="ctrl.formValues.ConfigMaps"
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
></config-maps-form-section>
|
||||||
Portainer will automatically expose all the keys of a ConfigMap as environment variables. This behavior can be overridden to filesystem mounts for each key
|
|
||||||
via the override option.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- config-element -->
|
|
||||||
<div class="form-inline clearfix" ng-repeat="(index, config) in ctrl.formValues.ConfigMaps">
|
|
||||||
<div class="col-sm-12 !p-0">
|
|
||||||
<div class="input-group input-group-sm !mr-1">
|
|
||||||
<span class="input-group-addon">name</span>
|
|
||||||
<select
|
|
||||||
class="form-control col-sm-6"
|
|
||||||
ng-model="config.SelectedConfiguration"
|
|
||||||
ng-options="c as c.Name for c in ctrl.configMaps track by c.Name"
|
|
||||||
ng-change="ctrl.resetConfigMap(index)"
|
|
||||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
|
||||||
data-cy="k8sAppCreate-addConfigSelect_{{ $index }}"
|
|
||||||
></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group btn-group btn-group-sm">
|
|
||||||
<label
|
|
||||||
class="btn btn-md btn-light vertical-center !ml-0"
|
|
||||||
type="button"
|
|
||||||
ng-click="ctrl.resetConfigMap(index)"
|
|
||||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
|
||||||
data-cy="k8sAppCreate-configAutoButton_{{ $index }}"
|
|
||||||
uib-btn-radio="false"
|
|
||||||
ng-model="config.Overriden"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'rotate-cw'" size="'md'"></pr-icon> Auto
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
class="btn btn-md btn-light vertical-center !ml-0"
|
|
||||||
ng-click="ctrl.overrideConfigMap(index)"
|
|
||||||
ng-disabled="!config.SelectedConfiguration || ctrl.formValues.Containers.length > 1"
|
|
||||||
data-cy="k8sAppCreate-configOverrideButton_{{ $index }}"
|
|
||||||
uib-btn-radio="true"
|
|
||||||
ng-model="config.Overriden"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'list'" size="'md'"></pr-icon> Override
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-md btn-dangerlight btn-only-icon vertical-center"
|
|
||||||
type="button"
|
|
||||||
ng-click="ctrl.removeConfigMap(index)"
|
|
||||||
ng-if="ctrl.formValues.Containers.length <= 1"
|
|
||||||
data-cy="k8sAppCreate-configRemoveButton"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- no-override -->
|
|
||||||
<div class="row clearfix" ng-if="config.SelectedConfiguration && !config.Overriden">
|
|
||||||
<div class="col-sm-9 small text-muted !mt-2 !p-0">
|
|
||||||
The following keys will be loaded from the <code>{{ config.SelectedConfiguration.Name }}</code>
|
|
||||||
ConfigMap as environment variables:
|
|
||||||
<span ng-repeat="(key, _) in config.SelectedConfiguration.Data">
|
|
||||||
<code>{{ key }}</code
|
|
||||||
>{{ $last ? '' : ', ' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !no-override -->
|
|
||||||
|
|
||||||
<!-- has-override -->
|
|
||||||
<div class="col-sm-12 !mt-2 !mb-4 !p-0" ng-if="config.Overriden" ng-repeat="(keyIndex, overridenKey) in config.OverridenKeys" style="margin-top: 2px">
|
|
||||||
<div class="input-group input-group-sm !mr-1">
|
|
||||||
<span class="input-group-addon">key</span>
|
|
||||||
<input type="text" class="form-control" ng-value="overridenKey.Key" disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group btn-group btn-group-sm !mr-1">
|
|
||||||
<label class="btn btn-light" ng-model="overridenKey.Type" uib-btn-radio="ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.ENVIRONMENT">
|
|
||||||
<pr-icon icon="'list'"></pr-icon> Environment
|
|
||||||
</label>
|
|
||||||
<label class="btn btn-light" ng-model="overridenKey.Type" uib-btn-radio="ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM">
|
|
||||||
<pr-icon icon="'file-text'"></pr-icon> Filesystem
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group !ml-0 !mr-0 !align-top" ng-if="overridenKey.Type === ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-addon required">path on disk</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="overridenKey.Path"
|
|
||||||
placeholder="/etc/myapp/conf.d"
|
|
||||||
name="overriden_key_configmap_path_{{ index }}_{{ keyIndex }}"
|
|
||||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
|
||||||
required
|
|
||||||
ng-change="ctrl.onChangeConfigMapPath()"
|
|
||||||
data-cy="k8sAppCreate-pathOnDiskInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="small"
|
|
||||||
ng-show="
|
|
||||||
kubernetesApplicationCreationForm['overriden_key_configmap_path_' + index + '_' + keyIndex].$invalid ||
|
|
||||||
ctrl.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] !== undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="text-warning" ng-if="overridenKey.Type === ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM">
|
|
||||||
<div
|
|
||||||
ng-show="
|
|
||||||
kubernetesApplicationCreationForm['overriden_key_configmap_path_' + index + '_' + keyIndex].$invalid ||
|
|
||||||
ctrl.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] !== undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<ng-messages for="kubernetesApplicationCreationForm['overriden_key_configmap_path_' + index + '_' + keyIndex].$error">
|
|
||||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Path is required.</p>
|
|
||||||
</ng-messages>
|
|
||||||
<p class="vertical-center" ng-if="ctrl.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] !== undefined">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This path is already used.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !has-override -->
|
|
||||||
</div>
|
|
||||||
<!-- !config-element -->
|
|
||||||
<div class="col-sm-12 !p-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0"
|
|
||||||
ng-disabled="ctrl.configMaps.length === 0"
|
|
||||||
ng-click="ctrl.addConfigMap()"
|
|
||||||
ng-if="ctrl.formValues.Containers.length <= 1"
|
|
||||||
data-cy="k8sAppCreate-addConfigButton"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Add ConfigMap
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="my-2 w-full">
|
|
||||||
<p class="vertical-center text-warning text-xs" ng-if="ctrl.configMaps.length === 0"
|
|
||||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no ConfigMaps available in this namespace.</p
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- #region SECRETS -->
|
<!-- #region SECRETS -->
|
||||||
<div class="form-group">
|
<secrets-form-section
|
||||||
<div class="col-sm-12 vertical-center pt-2.5">
|
values="ctrl.formValues.Secrets"
|
||||||
<label class="control-label !pt-0 text-left">Secrets</label>
|
on-change="(ctrl.onSecretsChange)"
|
||||||
</div>
|
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||||
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length">
|
validation-data="ctrl.formValues.Secrets"
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
></secrets-form-section>
|
||||||
Portainer will automatically expose all the keys of a Secret as environment variables. This behavior can be overridden to filesystem mounts for each key via
|
|
||||||
the override option.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- config-element -->
|
|
||||||
<div class="form-inline clearfix" ng-repeat="(index, config) in ctrl.formValues.Secrets">
|
|
||||||
<div class="col-sm-12 !p-0">
|
|
||||||
<div class="input-group input-group-sm !mr-1">
|
|
||||||
<span class="input-group-addon">name</span>
|
|
||||||
<select
|
|
||||||
class="form-control col-sm-6"
|
|
||||||
ng-model="config.SelectedConfiguration"
|
|
||||||
ng-options="c as c.Name for c in ctrl.secrets track by c.Name"
|
|
||||||
ng-change="ctrl.resetSecret(index)"
|
|
||||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
|
||||||
data-cy="k8sAppCreate-addSecretSelect_{{ $index }}"
|
|
||||||
></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group btn-group btn-group-sm">
|
|
||||||
<label
|
|
||||||
class="btn btn-md btn-light vertical-center !ml-0"
|
|
||||||
type="button"
|
|
||||||
ng-click="ctrl.resetSecret(index)"
|
|
||||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
|
||||||
data-cy="k8sAppCreate-secretAutoButton_{{ $index }}"
|
|
||||||
uib-btn-radio="false"
|
|
||||||
ng-model="config.Overriden"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'rotate-cw'" size="'md'"></pr-icon> Auto
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
class="btn btn-md btn-light vertical-center !ml-0"
|
|
||||||
ng-click="ctrl.overrideSecret(index)"
|
|
||||||
ng-disabled="!config.SelectedConfiguration || ctrl.formValues.Containers.length > 1"
|
|
||||||
data-cy="k8sAppCreate-secretOverrideButton_{{ $index }}"
|
|
||||||
uib-btn-radio="true"
|
|
||||||
ng-model="config.Overriden"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'list'" size="'md'"></pr-icon> Override
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-md btn-dangerlight btn-only-icon vertical-center"
|
|
||||||
type="button"
|
|
||||||
ng-click="ctrl.removeSecret(index)"
|
|
||||||
ng-if="ctrl.formValues.Containers.length <= 1"
|
|
||||||
data-cy="k8sAppCreate-secretRemoveButton"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- no-override -->
|
|
||||||
<div class="row clearfix" ng-if="config.SelectedConfiguration && !config.Overriden">
|
|
||||||
<div class="col-sm-9 small text-muted !mt-2 !p-0">
|
|
||||||
The following keys will be loaded from the <code>{{ config.SelectedConfiguration.Name }}</code> Secret as environment variables:
|
|
||||||
<span ng-repeat="(key, _) in config.SelectedConfiguration.Data">
|
|
||||||
<code>{{ key }}</code
|
|
||||||
>{{ $last ? '' : ', ' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !no-override -->
|
|
||||||
|
|
||||||
<!-- has-override -->
|
|
||||||
<div class="col-sm-12 !mt-2 !mb-4 !p-0" ng-if="config.Overriden" ng-repeat="(keyIndex, overridenKey) in config.OverridenKeys" style="margin-top: 2px">
|
|
||||||
<div class="input-group input-group-sm !mr-1">
|
|
||||||
<span class="input-group-addon">key</span>
|
|
||||||
<input type="text" class="form-control" ng-value="overridenKey.Key" disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group btn-group btn-group-sm !mr-1">
|
|
||||||
<label class="btn btn-light" ng-model="overridenKey.Type" uib-btn-radio="ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.ENVIRONMENT">
|
|
||||||
<pr-icon icon="'list'"></pr-icon> Environment
|
|
||||||
</label>
|
|
||||||
<label class="btn btn-light" ng-model="overridenKey.Type" uib-btn-radio="ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM">
|
|
||||||
<pr-icon icon="'file-text'"></pr-icon> Filesystem
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group !ml-0 !mr-0 !align-top" ng-if="overridenKey.Type === ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-addon required">path on disk</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="overridenKey.Path"
|
|
||||||
placeholder="/etc/myapp/conf.d"
|
|
||||||
name="overriden_key_secret_path_{{ index }}_{{ keyIndex }}"
|
|
||||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
|
||||||
required
|
|
||||||
ng-change="ctrl.onChangeSecretPath()"
|
|
||||||
data-cy="k8sAppCreate-secretPathOnDiskInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="small"
|
|
||||||
ng-show="
|
|
||||||
kubernetesApplicationCreationForm['overriden_key_secret_path_' + index + '_' + keyIndex].$invalid ||
|
|
||||||
ctrl.state.duplicates.secretPaths.refs[index + '_' + keyIndex] !== undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="text-warning" ng-if="overridenKey.Type === ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM">
|
|
||||||
<div
|
|
||||||
ng-show="
|
|
||||||
kubernetesApplicationCreationForm['overriden_key_secret_path_' + index + '_' + keyIndex].$invalid ||
|
|
||||||
ctrl.state.duplicates.secretPaths.refs[index + '_' + keyIndex] !== undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<ng-messages for="kubernetesApplicationCreationForm['overriden_key_secret_path_' + index + '_' + keyIndex].$error">
|
|
||||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Path is required.</p>
|
|
||||||
</ng-messages>
|
|
||||||
<p class="vertical-center" ng-if="ctrl.state.duplicates.secretPaths.refs[index + '_' + keyIndex] !== undefined"
|
|
||||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This path is already used.</p
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !has-override -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-2 w-full">
|
|
||||||
<p class="vertical-center text-muted text-xs" ng-if="ctrl.secrets.length === 0">
|
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
|
||||||
There are no secrets available in this namespace.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<!-- !config-element -->
|
|
||||||
<div class="col-sm-12 !p-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
ng-disabled="ctrl.secrets.length === 0"
|
|
||||||
class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0"
|
|
||||||
ng-click="ctrl.addSecret()"
|
|
||||||
ng-if="ctrl.formValues.Containers.length <= 1"
|
|
||||||
data-cy="k8sAppCreate-addSecretButton"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Add Secret
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- #endregion -->
|
<!-- #endregion -->
|
||||||
|
|
||||||
<!-- #region PERSISTED FOLDERS -->
|
<!-- #region PERSISTED FOLDERS -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12 vertical-center mb-2 pt-2.5" style="margin-top: 5px">
|
<div class="col-sm-12 vertical-center mb-2 pt-2.5" style="margin-top: 5px">
|
||||||
<label class="control-label !pt-0 text-left">Persisted folders</label>
|
<label class="control-label !pt-0 text-left !text-sm">Persisted folders</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-sm-12 small text-muted vertical-center mt-1" ng-if="!ctrl.storageClassAvailable()">
|
<div class="col-sm-12 small text-muted vertical-center mt-1" ng-if="!ctrl.storageClassAvailable()">
|
||||||
|
|
|
@ -17,9 +17,6 @@ import {
|
||||||
KubernetesDeploymentTypes,
|
KubernetesDeploymentTypes,
|
||||||
} from 'Kubernetes/models/application/models';
|
} from 'Kubernetes/models/application/models';
|
||||||
import {
|
import {
|
||||||
KubernetesApplicationConfigurationFormValue,
|
|
||||||
KubernetesApplicationConfigurationFormValueOverridenKey,
|
|
||||||
KubernetesApplicationConfigurationFormValueOverridenKeyTypes,
|
|
||||||
KubernetesApplicationEnvironmentVariableFormValue,
|
KubernetesApplicationEnvironmentVariableFormValue,
|
||||||
KubernetesApplicationFormValues,
|
KubernetesApplicationFormValues,
|
||||||
KubernetesApplicationPersistedFolderFormValue,
|
KubernetesApplicationPersistedFolderFormValue,
|
||||||
|
@ -85,7 +82,6 @@ class KubernetesCreateApplicationController {
|
||||||
this.ApplicationPublishingTypes = KubernetesApplicationPublishingTypes;
|
this.ApplicationPublishingTypes = KubernetesApplicationPublishingTypes;
|
||||||
this.ApplicationPlacementTypes = KubernetesApplicationPlacementTypes;
|
this.ApplicationPlacementTypes = KubernetesApplicationPlacementTypes;
|
||||||
this.ApplicationTypes = KubernetesApplicationTypes;
|
this.ApplicationTypes = KubernetesApplicationTypes;
|
||||||
this.ApplicationConfigurationFormValueOverridenKeyTypes = KubernetesApplicationConfigurationFormValueOverridenKeyTypes;
|
|
||||||
this.ServiceTypes = KubernetesServiceTypes;
|
this.ServiceTypes = KubernetesServiceTypes;
|
||||||
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
|
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
|
||||||
|
|
||||||
|
@ -155,6 +151,8 @@ class KubernetesCreateApplicationController {
|
||||||
this.onChangePlacementType = this.onChangePlacementType.bind(this);
|
this.onChangePlacementType = this.onChangePlacementType.bind(this);
|
||||||
this.onServicesChange = this.onServicesChange.bind(this);
|
this.onServicesChange = this.onServicesChange.bind(this);
|
||||||
this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this);
|
this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this);
|
||||||
|
this.onConfigMapsChange = this.onConfigMapsChange.bind(this);
|
||||||
|
this.onSecretsChange = this.onSecretsChange.bind(this);
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
|
@ -240,34 +238,12 @@ class KubernetesCreateApplicationController {
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region CONFIGMAP UI MANAGEMENT */
|
/* #region CONFIGMAP UI MANAGEMENT */
|
||||||
addConfigMap() {
|
onConfigMapsChange(configMaps) {
|
||||||
let config = new KubernetesApplicationConfigurationFormValue();
|
return this.$async(async () => {
|
||||||
config.SelectedConfiguration = this.configMaps[0];
|
this.formValues.ConfigMaps = configMaps;
|
||||||
this.formValues.ConfigMaps.push(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeConfigMap(index) {
|
|
||||||
this.formValues.ConfigMaps.splice(index, 1);
|
|
||||||
this.onChangeConfigMapPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
overrideConfigMap(index) {
|
|
||||||
const config = this.formValues.ConfigMaps[index];
|
|
||||||
config.Overriden = true;
|
|
||||||
config.OverridenKeys = _.map(_.keys(config.SelectedConfiguration.Data), (key) => {
|
|
||||||
const res = new KubernetesApplicationConfigurationFormValueOverridenKey();
|
|
||||||
res.Key = key;
|
|
||||||
return res;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resetConfigMap(index) {
|
|
||||||
const config = this.formValues.ConfigMaps[index];
|
|
||||||
config.Overriden = false;
|
|
||||||
config.OverridenKeys = [];
|
|
||||||
this.onChangeConfigMapPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearConfigMaps() {
|
clearConfigMaps() {
|
||||||
this.formValues.ConfigMaps = [];
|
this.formValues.ConfigMaps = [];
|
||||||
}
|
}
|
||||||
|
@ -278,7 +254,7 @@ class KubernetesCreateApplicationController {
|
||||||
const paths = _.reduce(
|
const paths = _.reduce(
|
||||||
this.formValues.ConfigMaps,
|
this.formValues.ConfigMaps,
|
||||||
(result, config) => {
|
(result, config) => {
|
||||||
const uniqOverridenKeysPath = _.uniq(_.map(config.OverridenKeys, 'Path'));
|
const uniqOverridenKeysPath = _.uniq(_.map(config.overridenKeys, 'path'));
|
||||||
return _.concat(result, uniqOverridenKeysPath);
|
return _.concat(result, uniqOverridenKeysPath);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
@ -287,8 +263,8 @@ class KubernetesCreateApplicationController {
|
||||||
const duplicatePaths = KubernetesFormValidationHelper.getDuplicates(paths);
|
const duplicatePaths = KubernetesFormValidationHelper.getDuplicates(paths);
|
||||||
|
|
||||||
_.forEach(this.formValues.ConfigMaps, (config, index) => {
|
_.forEach(this.formValues.ConfigMaps, (config, index) => {
|
||||||
_.forEach(config.OverridenKeys, (overridenKey, keyIndex) => {
|
_.forEach(config.overridenKeys, (overridenKey, keyIndex) => {
|
||||||
const findPath = _.find(duplicatePaths, (path) => path === overridenKey.Path);
|
const findPath = _.find(duplicatePaths, (path) => path === overridenKey.path);
|
||||||
if (findPath) {
|
if (findPath) {
|
||||||
this.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] = findPath;
|
this.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] = findPath;
|
||||||
}
|
}
|
||||||
|
@ -300,63 +276,15 @@ class KubernetesCreateApplicationController {
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region SECRET UI MANAGEMENT */
|
/* #region SECRET UI MANAGEMENT */
|
||||||
addSecret() {
|
onSecretsChange(secrets) {
|
||||||
let secret = new KubernetesApplicationConfigurationFormValue();
|
return this.$async(async () => {
|
||||||
secret.SelectedConfiguration = this.secrets[0];
|
this.formValues.Secrets = secrets;
|
||||||
this.formValues.Secrets.push(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeSecret(index) {
|
|
||||||
this.formValues.Secrets.splice(index, 1);
|
|
||||||
this.onChangeSecretPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
overrideSecret(index) {
|
|
||||||
const secret = this.formValues.Secrets[index];
|
|
||||||
secret.Overriden = true;
|
|
||||||
secret.OverridenKeys = _.map(_.keys(secret.SelectedConfiguration.Data), (key) => {
|
|
||||||
const res = new KubernetesApplicationConfigurationFormValueOverridenKey();
|
|
||||||
res.Key = key;
|
|
||||||
return res;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSecret(index) {
|
|
||||||
const secret = this.formValues.Secrets[index];
|
|
||||||
secret.Overriden = false;
|
|
||||||
secret.OverridenKeys = [];
|
|
||||||
this.onChangeSecretPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSecrets() {
|
clearSecrets() {
|
||||||
this.formValues.Secrets = [];
|
this.formValues.Secrets = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeSecretPath() {
|
|
||||||
this.state.duplicates.secretPaths.refs = [];
|
|
||||||
|
|
||||||
const paths = _.reduce(
|
|
||||||
this.formValues.Secrets,
|
|
||||||
(result, secret) => {
|
|
||||||
const uniqOverridenKeysPath = _.uniq(_.map(secret.OverridenKeys, 'Path'));
|
|
||||||
return _.concat(result, uniqOverridenKeysPath);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const duplicatePaths = KubernetesFormValidationHelper.getDuplicates(paths);
|
|
||||||
|
|
||||||
_.forEach(this.formValues.Secrets, (secret, index) => {
|
|
||||||
_.forEach(secret.OverridenKeys, (overridenKey, keyIndex) => {
|
|
||||||
const findPath = _.find(duplicatePaths, (path) => path === overridenKey.Path);
|
|
||||||
if (findPath) {
|
|
||||||
this.state.duplicates.secretPaths.refs[index + '_' + keyIndex] = findPath;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.state.duplicates.secretPaths.hasRefs = Object.keys(this.state.duplicates.secretPaths.refs).length > 0;
|
|
||||||
}
|
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region ENVIRONMENT UI MANAGEMENT */
|
/* #region ENVIRONMENT UI MANAGEMENT */
|
||||||
|
@ -920,7 +848,7 @@ class KubernetesCreateApplicationController {
|
||||||
try {
|
try {
|
||||||
this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username;
|
this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username;
|
||||||
// combine the secrets and configmap form values when submitting the form
|
// combine the secrets and configmap form values when submitting the form
|
||||||
_.remove(this.formValues.Configurations, (item) => item.SelectedConfiguration === undefined);
|
_.remove(this.formValues.Configurations, (item) => item.selectedConfiguration === undefined);
|
||||||
await this.KubernetesApplicationService.create(this.formValues, this.originalServicePorts, this.deploymentOptions.hideStacksFunctionality);
|
await this.KubernetesApplicationService.create(this.formValues, this.originalServicePorts, this.deploymentOptions.hideStacksFunctionality);
|
||||||
this.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name);
|
this.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name);
|
||||||
this.$state.go('kubernetes.applications');
|
this.$state.go('kubernetes.applications');
|
||||||
|
|
|
@ -71,7 +71,11 @@ interface Props<T> {
|
||||||
errors?: ArrayError<T[]>;
|
errors?: ArrayError<T[]>;
|
||||||
textTip?: string;
|
textTip?: string;
|
||||||
isAddButtonHidden?: boolean;
|
isAddButtonHidden?: boolean;
|
||||||
|
addButtonDataCy?: string;
|
||||||
|
isDeleteButtonHidden?: boolean;
|
||||||
|
deleteButtonDataCy?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
addButtonError?: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
}
|
}
|
||||||
|
@ -91,12 +95,17 @@ export function InputList<T = DefaultType>({
|
||||||
errors,
|
errors,
|
||||||
textTip,
|
textTip,
|
||||||
isAddButtonHidden = false,
|
isAddButtonHidden = false,
|
||||||
|
addButtonDataCy,
|
||||||
|
isDeleteButtonHidden = false,
|
||||||
|
deleteButtonDataCy,
|
||||||
disabled,
|
disabled,
|
||||||
|
addButtonError,
|
||||||
readOnly,
|
readOnly,
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const initialItemsCount = useRef(value.length);
|
const initialItemsCount = useRef(value.length);
|
||||||
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
|
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
|
||||||
|
const isDeleteButtonVisible = !(isDeleteButtonHidden || readOnly);
|
||||||
return (
|
return (
|
||||||
<div className="form-group" aria-label={ariaLabel || label}>
|
<div className="form-group" aria-label={ariaLabel || label}>
|
||||||
{label && (
|
{label && (
|
||||||
|
@ -160,16 +169,17 @@ export function InputList<T = DefaultType>({
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!readOnly && !canUndoDelete && (
|
{isDeleteButtonVisible && !canUndoDelete && (
|
||||||
<Button
|
<Button
|
||||||
color="dangerlight"
|
color="dangerlight"
|
||||||
size="medium"
|
size="medium"
|
||||||
onClick={() => handleRemoveItem(key, item)}
|
onClick={() => handleRemoveItem(key, item)}
|
||||||
className="vertical-center btn-only-icon"
|
className="vertical-center btn-only-icon"
|
||||||
|
data-cy={`${deleteButtonDataCy}_${index}`}
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!readOnly &&
|
{isDeleteButtonVisible &&
|
||||||
canUndoDelete &&
|
canUndoDelete &&
|
||||||
hasKey(item, 'needsDeletion') && (
|
hasKey(item, 'needsDeletion') && (
|
||||||
<CanUndoDeleteButton
|
<CanUndoDeleteButton
|
||||||
|
@ -188,19 +198,27 @@ export function InputList<T = DefaultType>({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAddButtonVisible && (
|
{isAddButtonVisible && (
|
||||||
<div className="col-sm-12 mt-3">
|
<>
|
||||||
<Button
|
<div className="col-sm-12 mt-3">
|
||||||
onClick={handleAdd}
|
<Button
|
||||||
disabled={disabled}
|
onClick={handleAdd}
|
||||||
type="button"
|
disabled={disabled}
|
||||||
color="default"
|
type="button"
|
||||||
className="!ml-0"
|
color="default"
|
||||||
size="small"
|
className="!ml-0"
|
||||||
icon={Plus}
|
size="small"
|
||||||
>
|
icon={Plus}
|
||||||
{addLabel}
|
data-cy={addButtonDataCy}
|
||||||
</Button>
|
>
|
||||||
</div>
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{addButtonError && (
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<FormError>{addButtonError}</FormError>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { RefAttributes } from 'react';
|
||||||
import ReactSelectType from 'react-select/dist/declarations/src/Select';
|
import ReactSelectType from 'react-select/dist/declarations/src/Select';
|
||||||
|
|
||||||
import './ReactSelect.css';
|
import './ReactSelect.css';
|
||||||
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
interface DefaultOption {
|
interface DefaultOption {
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -25,7 +26,8 @@ type RegularProps<
|
||||||
IsMulti,
|
IsMulti,
|
||||||
Group
|
Group
|
||||||
> &
|
> &
|
||||||
RefAttributes<ReactSelectType<Option, IsMulti, Group>>;
|
RefAttributes<ReactSelectType<Option, IsMulti, Group>> &
|
||||||
|
AutomationTestingProps;
|
||||||
|
|
||||||
type CreatableProps<
|
type CreatableProps<
|
||||||
Option = DefaultOption,
|
Option = DefaultOption,
|
||||||
|
@ -35,7 +37,8 @@ type CreatableProps<
|
||||||
Option,
|
Option,
|
||||||
IsMulti,
|
IsMulti,
|
||||||
Group
|
Group
|
||||||
>;
|
> &
|
||||||
|
AutomationTestingProps;
|
||||||
|
|
||||||
type Props<
|
type Props<
|
||||||
Option = DefaultOption,
|
Option = DefaultOption,
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { KeyToPath, Pod } from 'kubernetes-types/core/v1';
|
import { KeyToPath, Pod, Secret } from 'kubernetes-types/core/v1';
|
||||||
import { Asterisk, Plus } from 'lucide-react';
|
import { Asterisk, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useSecrets } from '@/react/kubernetes/configs/secret.service';
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
@ -15,6 +18,8 @@ type Props = {
|
||||||
export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
|
export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
|
||||||
const containerVolumeConfigs = getApplicationVolumeConfigs(app);
|
const containerVolumeConfigs = getApplicationVolumeConfigs(app);
|
||||||
|
|
||||||
|
const { data: secrets } = useSecrets(useEnvironmentId(), namespace);
|
||||||
|
|
||||||
if (containerVolumeConfigs.length === 0) {
|
if (containerVolumeConfigs.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -71,10 +76,19 @@ export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
|
||||||
{!item.key && '-'}
|
{!item.key && '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{volumeConfigName && (
|
{isVolumeConfigNameFromSecret(secrets, volumeConfigName) ? (
|
||||||
<Link
|
<Link
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
to="kubernetes.configurations.configuration"
|
to="kubernetes.secrets.secret"
|
||||||
|
params={{ name: volumeConfigName, namespace }}
|
||||||
|
>
|
||||||
|
<Icon icon={Plus} className="!mr-1" />
|
||||||
|
{volumeConfigName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
className="flex items-center"
|
||||||
|
to="kubernetes.configmaps.configmap"
|
||||||
params={{ name: volumeConfigName, namespace }}
|
params={{ name: volumeConfigName, namespace }}
|
||||||
>
|
>
|
||||||
<Icon icon={Plus} className="!mr-1" />
|
<Icon icon={Plus} className="!mr-1" />
|
||||||
|
@ -91,6 +105,13 @@ export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVolumeConfigNameFromSecret(
|
||||||
|
secrets?: Secret[],
|
||||||
|
volumeConfigName?: string
|
||||||
|
) {
|
||||||
|
return secrets?.some((secret) => secret.metadata?.name === volumeConfigName);
|
||||||
|
}
|
||||||
|
|
||||||
// getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume
|
// getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume
|
||||||
function getApplicationVolumeConfigs(app?: Application) {
|
function getApplicationVolumeConfigs(app?: Application) {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useConfigMaps } from '@/react/kubernetes/configs/configmap.service';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection/FormSection';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { InputList } from '@@/form-components/InputList';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
|
||||||
|
import { ConfigurationItem } from './ConfigurationItem';
|
||||||
|
import { ConfigurationFormValues } from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
values: ConfigurationFormValues[];
|
||||||
|
onChange: (values: ConfigurationFormValues[]) => void;
|
||||||
|
errors: FormikErrors<ConfigurationFormValues[]>;
|
||||||
|
namespace: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfigMapsFormSection({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
namespace,
|
||||||
|
}: Props) {
|
||||||
|
const configMapsQuery = useConfigMaps(useEnvironmentId(), namespace);
|
||||||
|
const configMaps = configMapsQuery.data || [];
|
||||||
|
|
||||||
|
if (configMapsQuery.isLoading) {
|
||||||
|
return <InlineLoader>Loading ConfigMaps...</InlineLoader>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title="ConfigMaps" titleSize="sm">
|
||||||
|
{!!values.length && (
|
||||||
|
<TextTip color="blue">
|
||||||
|
Portainer will automatically expose all the keys of a ConfigMap as
|
||||||
|
environment variables. This behavior can be overridden to filesystem
|
||||||
|
mounts for each key via the override option.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputList<ConfigurationFormValues>
|
||||||
|
value={values}
|
||||||
|
onChange={onChange}
|
||||||
|
errors={errors}
|
||||||
|
isDeleteButtonHidden
|
||||||
|
deleteButtonDataCy="k8sAppCreate-configRemoveButton"
|
||||||
|
addButtonDataCy="k8sAppCreate-configAddButton"
|
||||||
|
disabled={configMaps.length === 0}
|
||||||
|
addButtonError={
|
||||||
|
configMaps.length === 0
|
||||||
|
? 'There are no ConfigMaps available in this namespace.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
renderItem={(item, onChange, index, error) => (
|
||||||
|
<ConfigurationItem
|
||||||
|
item={item}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
configurations={configMaps}
|
||||||
|
onRemoveItem={() => onRemoveItem(index)}
|
||||||
|
index={index}
|
||||||
|
dataCyType="config"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
itemBuilder={() => ({
|
||||||
|
selectedConfigMap: configMaps[0]?.metadata?.name || '',
|
||||||
|
overriden: false,
|
||||||
|
overridenKeys: [],
|
||||||
|
selectedConfiguration: configMaps[0],
|
||||||
|
})}
|
||||||
|
addLabel="Add ConfigMap"
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
function onRemoveItem(index: number) {
|
||||||
|
onChange(values.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { List, RotateCw, Trash2 } from 'lucide-react';
|
||||||
|
import { ConfigMap, Secret } from 'kubernetes-types/core/v1';
|
||||||
|
import { SingleValue } from 'react-select';
|
||||||
|
|
||||||
|
import { InputGroup } from '@@/form-components/InputGroup';
|
||||||
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { ItemError } from '@@/form-components/InputList/InputList';
|
||||||
|
import { isErrorType } from '@@/form-components/formikUtils';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { ConfigurationFormValues, ConfigurationOverrideKey } from './types';
|
||||||
|
import { ConfigurationData } from './ConfigurationKey';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: ConfigurationFormValues;
|
||||||
|
onChange: (values: ConfigurationFormValues) => void;
|
||||||
|
onRemoveItem: () => void;
|
||||||
|
configurations: Array<ConfigMap | Secret>;
|
||||||
|
index: number;
|
||||||
|
error?: ItemError<ConfigurationFormValues>;
|
||||||
|
dataCyType: 'config' | 'secret';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfigurationItem({
|
||||||
|
item,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
configurations,
|
||||||
|
index,
|
||||||
|
onRemoveItem,
|
||||||
|
dataCyType,
|
||||||
|
}: Props) {
|
||||||
|
// rule out the error being of type string
|
||||||
|
const formikError = isErrorType(error) ? error : undefined;
|
||||||
|
const configurationData = item.selectedConfiguration.data || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<div className="flex items-start gap-x-2 gap-y-2">
|
||||||
|
<div>
|
||||||
|
<InputGroup size="small" className="min-w-[250px]">
|
||||||
|
<InputGroup.Addon>Name</InputGroup.Addon>
|
||||||
|
<Select
|
||||||
|
options={configurations}
|
||||||
|
isMulti={false}
|
||||||
|
getOptionLabel={(option) => option.metadata?.name || ''}
|
||||||
|
noOptionsMessage={() => 'No ConfigMaps found.'}
|
||||||
|
value={configurations.find(
|
||||||
|
(configuration) =>
|
||||||
|
configuration.metadata?.name ===
|
||||||
|
item.selectedConfiguration.metadata?.name
|
||||||
|
)}
|
||||||
|
onChange={onSelectConfigMap}
|
||||||
|
size="sm"
|
||||||
|
data-cy={`k8sAppCreate-add${dataCyType}Select_${index}`}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{formikError?.selectedConfiguration && (
|
||||||
|
<FormError>{formikError?.selectedConfiguration}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<InputGroup size="small">
|
||||||
|
<InputGroup.ButtonWrapper>
|
||||||
|
<Button
|
||||||
|
color="light"
|
||||||
|
size="medium"
|
||||||
|
className={clsx('!ml-0', { active: !item.overriden })}
|
||||||
|
onClick={() => onToggleOverride(false)}
|
||||||
|
icon={RotateCw}
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="light"
|
||||||
|
size="medium"
|
||||||
|
className={clsx('!ml-0 mr-1', { active: item.overriden })}
|
||||||
|
onClick={() => onToggleOverride(true)}
|
||||||
|
icon={List}
|
||||||
|
>
|
||||||
|
Override
|
||||||
|
</Button>
|
||||||
|
</InputGroup.ButtonWrapper>
|
||||||
|
</InputGroup>
|
||||||
|
<Button
|
||||||
|
color="dangerlight"
|
||||||
|
size="medium"
|
||||||
|
onClick={() => onRemoveItem()}
|
||||||
|
className="!ml-0 vertical-center btn-only-icon"
|
||||||
|
icon={Trash2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!item.overriden && (
|
||||||
|
<TextTip color="blue">
|
||||||
|
The following keys will be loaded from the{' '}
|
||||||
|
<code>{item.selectedConfiguration.metadata?.name}</code>
|
||||||
|
ConfigMap as environment variables:
|
||||||
|
{Object.keys(configurationData).map((key, index) => (
|
||||||
|
<span key={key}>
|
||||||
|
<code>{key}</code>
|
||||||
|
{index < Object.keys(configurationData).length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
{item.overriden &&
|
||||||
|
item.overridenKeys.map((overridenKey, keyIndex) => (
|
||||||
|
<ConfigurationData
|
||||||
|
key={overridenKey.key}
|
||||||
|
value={overridenKey}
|
||||||
|
onChange={(value: ConfigurationOverrideKey) => {
|
||||||
|
const newOverridenKeys = [...item.overridenKeys];
|
||||||
|
newOverridenKeys[keyIndex] = value;
|
||||||
|
onChange({ ...item, overridenKeys: newOverridenKeys });
|
||||||
|
}}
|
||||||
|
overrideKeysErrors={formikError?.overridenKeys}
|
||||||
|
dataCyType={dataCyType}
|
||||||
|
configurationIndex={index}
|
||||||
|
keyIndex={keyIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function onSelectConfigMap(configMap: SingleValue<ConfigMap | Secret>) {
|
||||||
|
if (configMap) {
|
||||||
|
onChange({
|
||||||
|
...item,
|
||||||
|
overriden: false,
|
||||||
|
selectedConfiguration: configMap,
|
||||||
|
overridenKeys: Object.keys(configMap.data || {}).map((key) => ({
|
||||||
|
key,
|
||||||
|
path: '',
|
||||||
|
type: 'NONE',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleOverride(overriden: boolean) {
|
||||||
|
onChange({
|
||||||
|
...item,
|
||||||
|
overriden,
|
||||||
|
overridenKeys: Object.keys(configurationData).map((key) => ({
|
||||||
|
key,
|
||||||
|
path: '',
|
||||||
|
type: overriden ? 'ENVIRONMENT' : 'NONE',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { RotateCw, List } from 'lucide-react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { InputGroup } from '@@/form-components/InputGroup';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { isArrayErrorType } from '@@/form-components/formikUtils';
|
||||||
|
|
||||||
|
import { ConfigurationOverrideKey } from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: ConfigurationOverrideKey;
|
||||||
|
onChange: (value: ConfigurationOverrideKey) => void;
|
||||||
|
configurationIndex: number;
|
||||||
|
keyIndex: number;
|
||||||
|
overrideKeysErrors?:
|
||||||
|
| string
|
||||||
|
| string[]
|
||||||
|
| FormikErrors<ConfigurationOverrideKey>[];
|
||||||
|
dataCyType: 'config' | 'secret';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfigurationData({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
overrideKeysErrors,
|
||||||
|
configurationIndex,
|
||||||
|
keyIndex,
|
||||||
|
dataCyType,
|
||||||
|
}: Props) {
|
||||||
|
// rule out the error (from formik) being of type string
|
||||||
|
const overriddenKeyError = isArrayErrorType(overrideKeysErrors)
|
||||||
|
? overrideKeysErrors[keyIndex]
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-x-2 gap-y-2 flex-wrap">
|
||||||
|
<InputGroup size="small" className="min-w-[250px]">
|
||||||
|
<InputGroup.Addon>Key</InputGroup.Addon>
|
||||||
|
<InputGroup.Input type="text" value={value.key} disabled />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup size="small">
|
||||||
|
<InputGroup.ButtonWrapper>
|
||||||
|
<Button
|
||||||
|
color="light"
|
||||||
|
size="medium"
|
||||||
|
className={clsx('!ml-0', { active: value.type === 'ENVIRONMENT' })}
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
path: '',
|
||||||
|
type: 'ENVIRONMENT',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
icon={RotateCw}
|
||||||
|
data-cy={`k8sAppCreate-${dataCyType}AutoButton_${configurationIndex}_${keyIndex}`}
|
||||||
|
>
|
||||||
|
Environment
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="light"
|
||||||
|
size="medium"
|
||||||
|
className={clsx('!ml-0 mr-1', {
|
||||||
|
active: value.type === 'FILESYSTEM',
|
||||||
|
})}
|
||||||
|
onClick={() => onChange({ ...value, path: '', type: 'FILESYSTEM' })}
|
||||||
|
icon={List}
|
||||||
|
data-cy={`k8sAppCreate-${dataCyType}OverrideButton_${configurationIndex}_${keyIndex}`}
|
||||||
|
>
|
||||||
|
File system
|
||||||
|
</Button>
|
||||||
|
</InputGroup.ButtonWrapper>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{value.type === 'FILESYSTEM' && (
|
||||||
|
<div>
|
||||||
|
<InputGroup size="small" className="min-w-[250px]">
|
||||||
|
<InputGroup.Addon required>Path on disk</InputGroup.Addon>
|
||||||
|
<InputGroup.Input
|
||||||
|
type="text"
|
||||||
|
value={value.path}
|
||||||
|
placeholder="e.g. /etc/myapp/conf.d"
|
||||||
|
onChange={(e) => onChange({ ...value, path: e.target.value })}
|
||||||
|
data-cy={`k8sAppCreate-${dataCyType}PathOnDiskInput_${configurationIndex}_${keyIndex}`}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{overriddenKeyError?.path && (
|
||||||
|
<FormError>{overriddenKeyError.path}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useSecrets } from '@/react/kubernetes/configs/secret.service';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection/FormSection';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { InputList } from '@@/form-components/InputList';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
|
||||||
|
import { ConfigurationItem } from './ConfigurationItem';
|
||||||
|
import { ConfigurationFormValues } from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
values: ConfigurationFormValues[];
|
||||||
|
onChange: (values: ConfigurationFormValues[]) => void;
|
||||||
|
errors: FormikErrors<ConfigurationFormValues[]>;
|
||||||
|
namespace: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SecretsFormSection({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
namespace,
|
||||||
|
}: Props) {
|
||||||
|
const secretsQuery = useSecrets(useEnvironmentId(), namespace);
|
||||||
|
const secrets = secretsQuery.data || [];
|
||||||
|
|
||||||
|
if (secretsQuery.isLoading) {
|
||||||
|
return <InlineLoader>Loading Secrets...</InlineLoader>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title="Secrets" titleSize="sm">
|
||||||
|
{!!values.length && (
|
||||||
|
<TextTip color="blue">
|
||||||
|
Portainer will automatically expose all the keys of a Secret as
|
||||||
|
environment variables. This behavior can be overridden to filesystem
|
||||||
|
mounts for each key via the override option.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputList<ConfigurationFormValues>
|
||||||
|
value={values}
|
||||||
|
onChange={onChange}
|
||||||
|
errors={errors}
|
||||||
|
isDeleteButtonHidden
|
||||||
|
deleteButtonDataCy="k8sAppCreate-secretRemoveButton"
|
||||||
|
addButtonDataCy="k8sAppCreate-secretAddButton"
|
||||||
|
disabled={secrets.length === 0}
|
||||||
|
addButtonError={
|
||||||
|
secrets.length === 0
|
||||||
|
? 'There are no Secrets available in this namespace.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
renderItem={(item, onChange, index, error) => (
|
||||||
|
<ConfigurationItem
|
||||||
|
item={item}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
configurations={secrets}
|
||||||
|
onRemoveItem={() => onRemoveItem(index)}
|
||||||
|
index={index}
|
||||||
|
dataCyType="secret"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
itemBuilder={() => ({
|
||||||
|
selectedConfigMap: secrets[0]?.metadata?.name || '',
|
||||||
|
overriden: false,
|
||||||
|
overridenKeys: [],
|
||||||
|
selectedConfiguration: secrets[0],
|
||||||
|
})}
|
||||||
|
addLabel="Add Secret"
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
function onRemoveItem(index: number) {
|
||||||
|
onChange(values.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { SchemaOf, array, boolean, mixed, object, string } from 'yup';
|
||||||
|
|
||||||
|
import { buildUniquenessTest } from '@@/form-components/validate-unique';
|
||||||
|
|
||||||
|
import { ConfigurationFormValues } from './types';
|
||||||
|
|
||||||
|
export function configurationsValidationSchema(
|
||||||
|
validationData?: ConfigurationFormValues[]
|
||||||
|
): SchemaOf<ConfigurationFormValues[]> {
|
||||||
|
return array(
|
||||||
|
object({
|
||||||
|
overriden: boolean().required(),
|
||||||
|
// skip validation for selectedConfiguration because it comes directly from a select dropdown
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
selectedConfiguration: object({} as any).required(),
|
||||||
|
overridenKeys: array(
|
||||||
|
object({
|
||||||
|
key: string().required(),
|
||||||
|
path: string().when('type', {
|
||||||
|
is: 'FILESYSTEM',
|
||||||
|
then: string()
|
||||||
|
.test(
|
||||||
|
'No duplicates globally',
|
||||||
|
'This path is already used.',
|
||||||
|
(path?: string) => {
|
||||||
|
const allPaths = validationData
|
||||||
|
?.flatMap((configmap) => configmap.overridenKeys)
|
||||||
|
.map((k) => k.path);
|
||||||
|
if (!allPaths) return true;
|
||||||
|
return (
|
||||||
|
allPaths.filter((p) => p === path && p !== '').length <= 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.required('Path is required.'),
|
||||||
|
}),
|
||||||
|
type: mixed().oneOf(['NONE', 'ENVIRONMENT', 'FILESYSTEM']),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'No duplicates',
|
||||||
|
'This path is already used.',
|
||||||
|
buildUniquenessTest(() => 'This path is already used.', 'path')
|
||||||
|
)
|
||||||
|
.required(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { ConfigMap, Secret } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
export type ConfigurationFormValues = {
|
||||||
|
overriden: boolean;
|
||||||
|
overridenKeys: ConfigurationOverrideKey[];
|
||||||
|
selectedConfiguration: ConfigMap | Secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationOverrideKey = {
|
||||||
|
key: string;
|
||||||
|
type: ConfigurationOverrideKeyType;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigurationOverrideKeyType = 'NONE' | 'ENVIRONMENT' | 'FILESYSTEM';
|
|
@ -1,4 +1,4 @@
|
||||||
import { ConfigMapList } from 'kubernetes-types/core/v1';
|
import { ConfigMap, ConfigMapList } from 'kubernetes-types/core/v1';
|
||||||
import { useMutation, useQuery } from 'react-query';
|
import { useMutation, useQuery } from 'react-query';
|
||||||
|
|
||||||
import { queryClient, withError } from '@/react-tools/react-query';
|
import { queryClient, withError } from '@/react-tools/react-query';
|
||||||
|
@ -133,7 +133,11 @@ async function getConfigMaps(environmentId: EnvironmentId, namespace: string) {
|
||||||
const { data } = await axios.get<ConfigMapList>(
|
const { data } = await axios.get<ConfigMapList>(
|
||||||
buildUrl(environmentId, namespace)
|
buildUrl(environmentId, namespace)
|
||||||
);
|
);
|
||||||
return data.items;
|
const configMapsWithKind: ConfigMap[] = data.items.map((configmap) => ({
|
||||||
|
...configmap,
|
||||||
|
kind: 'ConfigMap',
|
||||||
|
}));
|
||||||
|
return configMapsWithKind;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseKubernetesAxiosError(e, 'Unable to retrieve ConfigMaps');
|
throw parseKubernetesAxiosError(e, 'Unable to retrieve ConfigMaps');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { SecretList } from 'kubernetes-types/core/v1';
|
import { Secret, SecretList } from 'kubernetes-types/core/v1';
|
||||||
import { useMutation, useQuery } from 'react-query';
|
import { useMutation, useQuery } from 'react-query';
|
||||||
|
|
||||||
import { queryClient, withError } from '@/react-tools/react-query';
|
import { queryClient, withError } from '@/react-tools/react-query';
|
||||||
|
@ -129,7 +129,11 @@ async function getSecrets(environmentId: EnvironmentId, namespace: string) {
|
||||||
const { data } = await axios.get<SecretList>(
|
const { data } = await axios.get<SecretList>(
|
||||||
buildUrl(environmentId, namespace)
|
buildUrl(environmentId, namespace)
|
||||||
);
|
);
|
||||||
return data.items;
|
const secretsWithKind: Secret[] = data.items.map((secret) => ({
|
||||||
|
...secret,
|
||||||
|
kind: 'Secret',
|
||||||
|
}));
|
||||||
|
return secretsWithKind;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseKubernetesAxiosError(e, 'Unable to retrieve secrets');
|
throw parseKubernetesAxiosError(e, 'Unable to retrieve secrets');
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue