mirror of https://github.com/portainer/portainer
feat(config): separate configmaps and secrets [EE-5078] (#9029)
parent
4a331b71e1
commit
d7fc2046d7
|
@ -9,21 +9,6 @@ import (
|
|||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
// @id getKubernetesConfigMapsAndSecrets
|
||||
// @summary Get ConfigMaps and Secrets
|
||||
// @description Get all ConfigMaps and Secrets for a given namespace
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment (Endpoint) identifier"
|
||||
// @param namespace path string true "Namespace name"
|
||||
// @success 200 {array} kubernetes.K8sConfigMapOrSecret "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/configuration [get]
|
||||
func (handler *Handler) getKubernetesConfigMapsAndSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
|
|
|
@ -223,30 +223,64 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
|||
|
||||
const configurations = {
|
||||
name: 'kubernetes.configurations',
|
||||
url: '/configurations',
|
||||
url: '/configurations?tab',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesConfigurationsView',
|
||||
component: 'kubernetesConfigMapsAndSecretsView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
tab: null,
|
||||
},
|
||||
};
|
||||
const configmaps = {
|
||||
name: 'kubernetes.configmaps',
|
||||
url: '/configmaps',
|
||||
abstract: true,
|
||||
};
|
||||
|
||||
const configurationCreation = {
|
||||
name: 'kubernetes.configurations.new',
|
||||
const configMapCreation = {
|
||||
name: 'kubernetes.configmaps.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesCreateConfigurationView',
|
||||
component: 'kubernetesCreateConfigMapView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const configuration = {
|
||||
name: 'kubernetes.configurations.configuration',
|
||||
const configMap = {
|
||||
name: 'kubernetes.configmaps.configmap',
|
||||
url: '/:namespace/:name',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesConfigurationView',
|
||||
component: 'kubernetesConfigMapView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const secrets = {
|
||||
name: 'kubernetes.secrets',
|
||||
url: '/secrets',
|
||||
abstract: true,
|
||||
};
|
||||
|
||||
const secretCreation = {
|
||||
name: 'kubernetes.secrets.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesCreateSecretView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const secret = {
|
||||
name: 'kubernetes.secrets.secret',
|
||||
url: '/:namespace/:name',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesSecretView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -293,7 +327,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
|||
|
||||
const deploy = {
|
||||
name: 'kubernetes.deploy',
|
||||
url: '/deploy?templateId&referrer',
|
||||
url: '/deploy?templateId&referrer&tab',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesDeployView',
|
||||
|
@ -418,8 +452,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
|||
$stateRegistryProvider.register(stack);
|
||||
$stateRegistryProvider.register(stackLogs);
|
||||
$stateRegistryProvider.register(configurations);
|
||||
$stateRegistryProvider.register(configurationCreation);
|
||||
$stateRegistryProvider.register(configuration);
|
||||
$stateRegistryProvider.register(configmaps);
|
||||
$stateRegistryProvider.register(configMapCreation);
|
||||
$stateRegistryProvider.register(secrets);
|
||||
$stateRegistryProvider.register(secretCreation);
|
||||
$stateRegistryProvider.register(configMap);
|
||||
$stateRegistryProvider.register(secret);
|
||||
$stateRegistryProvider.register(cluster);
|
||||
$stateRegistryProvider.register(dashboard);
|
||||
$stateRegistryProvider.register(deploy);
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesConfigurationsDatatable', {
|
||||
templateUrl: './configurationsDatatable.html',
|
||||
controller: 'KubernetesConfigurationsDatatableController',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
refreshCallback: '<',
|
||||
removeAction: '<',
|
||||
},
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { PorImageRegistryModel } from '@/docker/models/porImageRegistry';
|
||||
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationPublishingTypes, KubernetesApplicationPlacementTypes } from './models';
|
||||
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationPlacementTypes } from './models';
|
||||
|
||||
/**
|
||||
* KubernetesApplicationFormValues Model
|
||||
|
@ -22,8 +22,8 @@ export function KubernetesApplicationFormValues() {
|
|||
this.EnvironmentVariables = []; // KubernetesApplicationEnvironmentVariableFormValue lis;
|
||||
this.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.ISOLATED;
|
||||
this.PersistedFolders = []; // KubernetesApplicationPersistedFolderFormValue lis;
|
||||
this.Configurations = []; // KubernetesApplicationConfigurationFormValue lis;
|
||||
this.PublishingType = KubernetesApplicationPublishingTypes.CLUSTER_IP;
|
||||
this.ConfigMaps = [];
|
||||
this.Secrets = [];
|
||||
this.PublishedPorts = []; // KubernetesApplicationPublishedPortFormValue lis;
|
||||
this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED;
|
||||
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
|
||||
|
|
|
@ -6,9 +6,10 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
|||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
||||
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
||||
import { DashboardView } from '@/react/kubernetes/DashboardView';
|
||||
import { ServicesView } from '@/react/kubernetes/ServicesView';
|
||||
import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView';
|
||||
import { ServicesView } from '@/react/kubernetes/services/ServicesView';
|
||||
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
|
||||
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.kubernetes.react.views', [])
|
||||
|
@ -27,6 +28,13 @@ export const viewsModule = angular
|
|||
'kubernetesIngressesCreateView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesConfigMapsAndSecretsView',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withCurrentUser(ConfigmapsAndSecretsView))),
|
||||
[]
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'kubernetesDashboardView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])
|
||||
|
|
|
@ -349,28 +349,28 @@
|
|||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region CONFIGURATIONS -->
|
||||
<!-- #region CONFIGMAPS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<label class="control-label !pt-0 text-left">ConfigMap or Secret</label>
|
||||
<label class="control-label !pt-0 text-left">ConfigMap</label>
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Configurations.length">
|
||||
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Portainer will automatically expose all the keys of a ConfigMap or Secret as environment variables. This behavior can be overridden to filesystem mounts for
|
||||
each key via the override option.
|
||||
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.Configurations">
|
||||
<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.configurations track by c.Name"
|
||||
ng-change="ctrl.resetConfiguration(index)"
|
||||
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>
|
||||
|
@ -380,7 +380,7 @@
|
|||
<label
|
||||
class="btn btn-md btn-light vertical-center !ml-0"
|
||||
type="button"
|
||||
ng-click="ctrl.resetConfiguration(index)"
|
||||
ng-click="ctrl.resetConfigMap(index)"
|
||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
||||
data-cy="k8sAppCreate-configAutoButton_{{ $index }}"
|
||||
uib-btn-radio="false"
|
||||
|
@ -390,7 +390,7 @@
|
|||
</label>
|
||||
<label
|
||||
class="btn btn-md btn-light vertical-center !ml-0"
|
||||
ng-click="ctrl.overrideConfiguration(index)"
|
||||
ng-click="ctrl.overrideConfigMap(index)"
|
||||
ng-disabled="!config.SelectedConfiguration || ctrl.formValues.Containers.length > 1"
|
||||
data-cy="k8sAppCreate-configOverrideButton_{{ $index }}"
|
||||
uib-btn-radio="true"
|
||||
|
@ -403,7 +403,7 @@
|
|||
<button
|
||||
class="btn btn-md btn-dangerlight btn-only-icon vertical-center"
|
||||
type="button"
|
||||
ng-click="ctrl.removeConfiguration(index)"
|
||||
ng-click="ctrl.removeConfigMap(index)"
|
||||
ng-if="ctrl.formValues.Containers.length <= 1"
|
||||
data-cy="k8sAppCreate-configRemoveButton"
|
||||
>
|
||||
|
@ -414,8 +414,7 @@
|
|||
<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>
|
||||
<span ng-if="config.SelectedConfiguration.Kind === 1">ConfigMap</span><span ng-if="config.SelectedConfiguration.Kind === 2">Secret</span> as environment
|
||||
variables:
|
||||
ConfigMap as environment variables:
|
||||
<span ng-repeat="(key, _) in config.SelectedConfiguration.Data">
|
||||
<code>{{ key }}</code
|
||||
>{{ $last ? '' : ', ' }}
|
||||
|
@ -451,7 +450,7 @@
|
|||
name="overriden_key_path_{{ index }}_{{ keyIndex }}"
|
||||
ng-disabled="ctrl.formValues.Containers.length > 1"
|
||||
required
|
||||
ng-change="ctrl.onChangeConfigurationPath()"
|
||||
ng-change="ctrl.onChangeConfigMapPath()"
|
||||
data-cy="k8sAppCreate-pathOnDiskInput"
|
||||
/>
|
||||
</div>
|
||||
|
@ -486,11 +485,154 @@
|
|||
<div class="col-sm-12 !p-0">
|
||||
<span
|
||||
class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0"
|
||||
ng-click="ctrl.addConfiguration()"
|
||||
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 and Secret
|
||||
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Add ConfigMap
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- #region SECRETS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<label class="control-label !pt-0 text-left">Secret</label>
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Configurations.length">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
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_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_path_' + index + '_' + keyIndex].$invalid ||
|
||||
ctrl.state.duplicates.configurationPaths.refs[index + '_' + keyIndex] !== undefined
|
||||
"
|
||||
>
|
||||
<div class="text-warning" ng-if="overridenKey.Type === ctrl.ApplicationConfigurationFormValueOverridenKeyTypes.FILESYSTEM">
|
||||
<div
|
||||
ng-show="
|
||||
kubernetesApplicationCreationForm['overriden_key_path_' + index + '_' + keyIndex].$invalid ||
|
||||
ctrl.state.duplicates.configurationPaths.refs[index + '_' + keyIndex] !== undefined
|
||||
"
|
||||
>
|
||||
<ng-messages for="kubernetesApplicationCreationForm['overriden_key_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.configurationPaths.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">
|
||||
<span
|
||||
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
|
||||
</span>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
|
|
@ -4,6 +4,7 @@ import filesizeParser from 'filesize-parser';
|
|||
import * as JsonPatch from 'fast-json-patch';
|
||||
import { RegistryTypes } from '@/portainer/models/registryTypes';
|
||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
|
||||
import {
|
||||
KubernetesApplicationDataAccessPolicies,
|
||||
|
@ -240,20 +241,20 @@ class KubernetesCreateApplicationController {
|
|||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region CONFIGURATION UI MANAGEMENT */
|
||||
addConfiguration() {
|
||||
/* #region CONFIGMAP UI MANAGEMENT */
|
||||
addConfigMap() {
|
||||
let config = new KubernetesApplicationConfigurationFormValue();
|
||||
config.SelectedConfiguration = this.configurations[0];
|
||||
this.formValues.Configurations.push(config);
|
||||
config.SelectedConfiguration = this.configMaps[0];
|
||||
this.formValues.ConfigMaps.push(config);
|
||||
}
|
||||
|
||||
removeConfiguration(index) {
|
||||
this.formValues.Configurations.splice(index, 1);
|
||||
this.onChangeConfigurationPath();
|
||||
removeConfigMap(index) {
|
||||
this.formValues.ConfigMaps.splice(index, 1);
|
||||
this.onChangeConfigMapPath();
|
||||
}
|
||||
|
||||
overrideConfiguration(index) {
|
||||
const config = this.formValues.Configurations[index];
|
||||
overrideConfigMap(index) {
|
||||
const config = this.formValues.ConfigMaps[index];
|
||||
config.Overriden = true;
|
||||
config.OverridenKeys = _.map(_.keys(config.SelectedConfiguration.Data), (key) => {
|
||||
const res = new KubernetesApplicationConfigurationFormValueOverridenKey();
|
||||
|
@ -262,22 +263,22 @@ class KubernetesCreateApplicationController {
|
|||
});
|
||||
}
|
||||
|
||||
resetConfiguration(index) {
|
||||
const config = this.formValues.Configurations[index];
|
||||
resetConfigMap(index) {
|
||||
const config = this.formValues.ConfigMaps[index];
|
||||
config.Overriden = false;
|
||||
config.OverridenKeys = [];
|
||||
this.onChangeConfigurationPath();
|
||||
this.onChangeConfigMapPath();
|
||||
}
|
||||
|
||||
clearConfigurations() {
|
||||
this.formValues.Configurations = [];
|
||||
clearConfigMaps() {
|
||||
this.formValues.ConfigMaps = [];
|
||||
}
|
||||
|
||||
onChangeConfigurationPath() {
|
||||
onChangeConfigMapPath() {
|
||||
this.state.duplicates.configurationPaths.refs = [];
|
||||
|
||||
const paths = _.reduce(
|
||||
this.formValues.Configurations,
|
||||
this.formValues.ConfigMaps,
|
||||
(result, config) => {
|
||||
const uniqOverridenKeysPath = _.uniq(_.map(config.OverridenKeys, 'Path'));
|
||||
return _.concat(result, uniqOverridenKeysPath);
|
||||
|
@ -287,7 +288,7 @@ class KubernetesCreateApplicationController {
|
|||
|
||||
const duplicatePaths = KubernetesFormValidationHelper.getDuplicates(paths);
|
||||
|
||||
_.forEach(this.formValues.Configurations, (config, index) => {
|
||||
_.forEach(this.formValues.ConfigMaps, (config, index) => {
|
||||
_.forEach(config.OverridenKeys, (overridenKey, keyIndex) => {
|
||||
const findPath = _.find(duplicatePaths, (path) => path === overridenKey.Path);
|
||||
if (findPath) {
|
||||
|
@ -300,6 +301,66 @@ class KubernetesCreateApplicationController {
|
|||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region SECRET UI MANAGEMENT */
|
||||
addSecret() {
|
||||
let secret = new KubernetesApplicationConfigurationFormValue();
|
||||
secret.SelectedConfiguration = this.secrets[0];
|
||||
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() {
|
||||
this.formValues.Secrets = [];
|
||||
}
|
||||
|
||||
onChangeSecretPath() {
|
||||
this.state.duplicates.configurationPaths.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.configurationPaths.refs[index + '_' + keyIndex] = findPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.state.duplicates.configurationPaths.hasRefs = Object.keys(this.state.duplicates.configurationPaths.refs).length > 0;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region ENVIRONMENT UI MANAGEMENT */
|
||||
addEnvironmentVariable() {
|
||||
this.formValues.EnvironmentVariables.push(new KubernetesApplicationEnvironmentVariableFormValue());
|
||||
|
@ -959,6 +1020,8 @@ class KubernetesCreateApplicationController {
|
|||
return this.$async(async () => {
|
||||
try {
|
||||
this.configurations = await this.KubernetesConfigurationService.get(namespace);
|
||||
this.configMaps = this.configurations.filter((configuration) => configuration.Kind === KubernetesConfigurationKinds.CONFIGMAP);
|
||||
this.secrets = this.configurations.filter((configuration) => configuration.Kind === KubernetesConfigurationKinds.SECRET);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve configurations');
|
||||
}
|
||||
|
@ -1029,7 +1092,8 @@ class KubernetesCreateApplicationController {
|
|||
}
|
||||
|
||||
resetFormValues() {
|
||||
this.clearConfigurations();
|
||||
this.clearConfigMaps();
|
||||
this.clearSecrets();
|
||||
this.resetPersistedFolders();
|
||||
this.resetPublishedPorts();
|
||||
}
|
||||
|
@ -1051,6 +1115,8 @@ class KubernetesCreateApplicationController {
|
|||
this.state.actionInProgress = true;
|
||||
try {
|
||||
this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username;
|
||||
// combine the secrets and configmap form values when submitting the form
|
||||
this.formValues.Configurations = [...this.formValues.ConfigMaps, ...this.formValues.Secrets];
|
||||
_.remove(this.formValues.Configurations, (item) => item.SelectedConfiguration === undefined);
|
||||
await this.KubernetesApplicationService.create(this.formValues);
|
||||
this.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name);
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
<page-header
|
||||
ng-if="ctrl.state.viewReady"
|
||||
title="'Create ConfigMap'"
|
||||
breadcrumbs="[{ label:'ConfigMaps and Secrets', link:'kubernetes.configurations', linkParams:{ tab: 'configmaps' } }, 'Create a ConfigMap']"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
|
||||
<!-- resource-pool -->
|
||||
<div class="form-group" ng-if="ctrl.formValues.ResourcePool">
|
||||
<label for="resource-pool-selector" class="col-sm-3 col-lg-2 control-label text-left">Namespace</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<select
|
||||
class="form-control"
|
||||
id="resource-pool-selector"
|
||||
ng-model="ctrl.formValues.ResourcePool"
|
||||
ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools"
|
||||
ng-change="ctrl.onResourcePoolSelectionChange()"
|
||||
data-cy="k8sConfigCreate-namespaceDropdown"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool">
|
||||
<div class="col-sm-12 small text-warning vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. 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 vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
You do not have access to any namespace. Contact your administrator to get access to a namespace.
|
||||
</div>
|
||||
</div>
|
||||
<!-- !resource-pool -->
|
||||
|
||||
<!-- name -->
|
||||
<div class="form-group">
|
||||
<label for="configuration_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
||||
<div class="col-sm-8 col-lg-9 mb-0">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="configuration_name"
|
||||
ng-model="ctrl.formValues.Name"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/"
|
||||
ng-change="ctrl.onChangeName()"
|
||||
placeholder="my-configmap"
|
||||
auto-focus
|
||||
required
|
||||
data-cy="k8sConfigCreate-nameInput"
|
||||
/>
|
||||
<div ng-show="kubernetesConfigurationCreationForm.configuration_name.$invalid || ctrl.state.alreadyExist">
|
||||
<div class="help-block small text-warning">
|
||||
<div ng-messages="kubernetesConfigurationCreationForm.configuration_name.$error">
|
||||
<p ng-message="required" class="vertical-center"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p>
|
||||
<p ng-message="pattern" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" class="vertical-center"></pr-icon> This field must consist of lower case alphanumeric characters, '-' or
|
||||
'.', and contain at most 63 characters, and must start and end with an alphanumeric character.</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.alreadyExist" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> A configuration with the same name already exists inside the selected namespace.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name -->
|
||||
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.formValues.ResourcePool">
|
||||
<kubernetes-configuration-data
|
||||
ng-if="ctrl.formValues"
|
||||
form-values="ctrl.formValues"
|
||||
is-docker-config="ctrl.state.isDockerConfig"
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="true"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
</div>
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view
|
||||
ng-if="!(!kubernetesConfigurationCreationForm.$valid || !ctrl.isFormValid() || ctrl.state.actionInProgress)"
|
||||
form-values="ctrl.formValues"
|
||||
></kubernetes-summary-view>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title" style="margin-top: 10px"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!kubernetesConfigurationCreationForm.$valid || !ctrl.isFormValid() || ctrl.state.actionInProgress || !ctrl.formValues.ResourcePool"
|
||||
ng-click="ctrl.createConfiguration()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sConfigCreate-CreateConfigButton"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Create {{ ctrl.formValues.Kind | kubernetesConfigurationKindText }}</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesCreateConfigMapView', {
|
||||
templateUrl: './createConfigMap.html',
|
||||
controller: 'KubernetesCreateConfigMapController',
|
||||
controllerAs: 'ctrl',
|
||||
});
|
|
@ -0,0 +1,170 @@
|
|||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
|
||||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { getServiceAccounts } from 'Kubernetes/rest/serviceAccount';
|
||||
import { typeOptions } from '@/react/kubernetes/configs/CreateView/options';
|
||||
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { isConfigurationFormValid } from '../../validation';
|
||||
|
||||
class KubernetesCreateConfigMapController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $scope, $window, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$scope = $scope;
|
||||
this.$window = $window;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.Notifications = Notifications;
|
||||
this.Authentication = Authentication;
|
||||
this.KubernetesConfigurationService = KubernetesConfigurationService;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
|
||||
|
||||
this.typeOptions = typeOptions;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.createConfigurationAsync = this.createConfigurationAsync.bind(this);
|
||||
this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this);
|
||||
this.onResourcePoolSelectionChangeAsync = this.onResourcePoolSelectionChangeAsync.bind(this);
|
||||
}
|
||||
|
||||
onChangeName() {
|
||||
const filteredConfigurations = _.filter(
|
||||
this.configurations,
|
||||
(config) => config.Namespace === this.formValues.ResourcePool.Namespace.Name && config.Kind === this.formValues.Kind
|
||||
);
|
||||
this.state.alreadyExist = _.find(filteredConfigurations, (config) => config.Name === this.formValues.Name) !== undefined;
|
||||
}
|
||||
|
||||
async onResourcePoolSelectionChangeAsync() {
|
||||
try {
|
||||
this.onChangeName();
|
||||
this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.formValues.ResourcePool.Namespace.Name);
|
||||
this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : '';
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load service accounts');
|
||||
}
|
||||
}
|
||||
onResourcePoolSelectionChange() {
|
||||
this.$async(this.onResourcePoolSelectionChangeAsync);
|
||||
}
|
||||
|
||||
addRequiredKeysToForm(keys) {
|
||||
// remove data entries that have an empty value
|
||||
this.formValues.Data = this.formValues.Data.filter((entry) => entry.Value);
|
||||
|
||||
keys.forEach((key) => {
|
||||
// if the key doesn't exist on the form, add a new formValues.Data entry
|
||||
if (!this.formValues.Data.some((data) => data.Key === key)) {
|
||||
this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry());
|
||||
const index = this.formValues.Data.length - 1;
|
||||
this.formValues.Data[index].Key = key;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isFormValid() {
|
||||
const [isValid] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
async createConfigurationAsync() {
|
||||
try {
|
||||
this.state.actionInProgress = true;
|
||||
this.formValues.ConfigurationOwner = this.Authentication.getUserDetails().username;
|
||||
if (!this.formValues.IsSimple) {
|
||||
this.formValues.Data = KubernetesConfigurationHelper.parseYaml(this.formValues);
|
||||
}
|
||||
|
||||
await this.KubernetesConfigurationService.create(this.formValues);
|
||||
this.Notifications.success('Success', `ConfigMap successfully created`);
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.configurations', { tab: 'configmaps' });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, `Unable to create ConfigMap`);
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
createConfiguration() {
|
||||
return this.$async(this.createConfigurationAsync);
|
||||
}
|
||||
|
||||
async getConfigurationsAsync() {
|
||||
try {
|
||||
this.configurations = await this.KubernetesConfigurationService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve ConfigMaps');
|
||||
}
|
||||
}
|
||||
|
||||
getConfigurations() {
|
||||
return this.$async(this.getConfigurationsAsync);
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) {
|
||||
return confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
viewReady: false,
|
||||
alreadyExist: false,
|
||||
isDataValid: true,
|
||||
isEditorDirty: false,
|
||||
isDockerConfig: false,
|
||||
};
|
||||
|
||||
this.formValues = new KubernetesConfigurationFormValues();
|
||||
this.formValues.Kind = KubernetesConfigurationKinds.CONFIGMAP;
|
||||
this.formValues.Data = [new KubernetesConfigurationFormValuesEntry()];
|
||||
|
||||
try {
|
||||
const resourcePools = await this.KubernetesResourcePoolService.get();
|
||||
this.resourcePools = _.filter(
|
||||
resourcePools,
|
||||
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
|
||||
);
|
||||
|
||||
this.formValues.ResourcePool = this.resourcePools[0];
|
||||
if (!this.formValues.ResourcePool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.getConfigurations();
|
||||
|
||||
this.environmentId = this.EndpointProvider.endpointID();
|
||||
this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.resourcePools[0].Namespace.Name);
|
||||
this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : '';
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.state.isEditorDirty = false;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesCreateConfigMapController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesCreateConfigMapController', KubernetesCreateConfigMapController);
|
|
@ -0,0 +1,173 @@
|
|||
<page-header
|
||||
ng-if="ctrl.state.viewReady"
|
||||
title="'ConfigMap details'"
|
||||
breadcrumbs="[
|
||||
{ label:'Namespaces', link:'kubernetes.resourcePools' },
|
||||
{
|
||||
label:ctrl.configuration.Namespace,
|
||||
link: 'kubernetes.resourcePools.resourcePool',
|
||||
linkParams:{ id: ctrl.configuration.Namespace }
|
||||
},
|
||||
{ label:'ConfigMaps and Secrets', link:'kubernetes.configurations', linkParams:{ tab: 'configmaps' } },
|
||||
ctrl.configuration.Name,
|
||||
]"
|
||||
reload="true"
|
||||
>
|
||||
</page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
|
||||
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)" data-cy="k8sConfigDetail-configTab">
|
||||
<uib-tab-heading>
|
||||
<pr-icon icon="'file-code'"></pr-icon>
|
||||
ConfigMap
|
||||
</uib-tab-heading>
|
||||
<div style="padding: 20px">
|
||||
<table class="table" data-cy="k8sConfigDetail-configTable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-[40%] !border-none !pl-0">Name</td>
|
||||
<td class="!border-none">
|
||||
{{ ctrl.configuration.Name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="!pl-0">Namespace</td>
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.configuration.Namespace })">{{ ctrl.configuration.Namespace }}</a>
|
||||
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace()">system</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</uib-tab>
|
||||
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)" data-cy="k8sConfigDetail-eventsTab">
|
||||
<uib-tab-heading>
|
||||
<pr-icon icon="'history'"></pr-icon>
|
||||
Events
|
||||
<div ng-if="ctrl.hasEventWarnings()">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
{{ ctrl.state.eventWarningCount }} warning(s)
|
||||
</div>
|
||||
</uib-tab-heading>
|
||||
<kubernetes-events-datatable
|
||||
title-text="Events"
|
||||
title-icon="history"
|
||||
dataset="ctrl.events"
|
||||
table-key="kubernetes.configuration.events"
|
||||
order-by="Date"
|
||||
reverse-order="true"
|
||||
loading="ctrl.state.eventsLoading"
|
||||
refresh-callback="ctrl.getEvents"
|
||||
>
|
||||
</kubernetes-events-datatable>
|
||||
</uib-tab>
|
||||
<uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab">
|
||||
<uib-tab-heading>
|
||||
<pr-icon icon="'code'"></pr-icon>
|
||||
YAML
|
||||
</uib-tab-heading>
|
||||
<div class="px-5 !pt-5" ng-if="ctrl.state.showEditorTab">
|
||||
<kubernetes-yaml-inspector key="configuration-yaml" data="ctrl.configuration.Yaml"></kubernetes-yaml-inspector>
|
||||
</div>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form ng-if="!ctrl.isSystemConfig()" class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<kubernetes-configuration-data
|
||||
ng-if="ctrl.formValues"
|
||||
form-values="ctrl.formValues"
|
||||
is-docker-config="false"
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="false"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view
|
||||
ng-if="!(!ctrl.isFormValid() || !kubernetesConfigurationCreationForm.$valid || ctrl.state.actionInProgress)"
|
||||
form-values="ctrl.formValues"
|
||||
></kubernetes-summary-view>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title" style="margin-top: 10px"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!ctrl.isFormValid() || !kubernetesConfigurationCreationForm.$valid || ctrl.state.actionInProgress"
|
||||
ng-click="ctrl.updateConfiguration()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sConfigDetail-updateConfig"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Update {{ ctrl.configuration.Kind | kubernetesConfigurationKindText }}</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Update in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
<div ng-if="ctrl.isSystemConfig()">
|
||||
<div class="col-sm-12 form-section-title" style="margin-top: 10px"> Data </div>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr class="text-muted">
|
||||
<td style="width: 10%; border-top: none">Key</td>
|
||||
<td style="width: 90%; border-top: none">Value</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-repeat="item in ctrl.formValues.Data track by $index">
|
||||
<td>{{ item.Key }}</td>
|
||||
<td>
|
||||
<div style="white-space: pre-wrap">{{ item.Value }}</div>
|
||||
<div style="margin-top: 2px">
|
||||
<span class="btn btn-primary btn-xs" ng-click="ctrl.copyConfigurationValue($index)"> <pr-icon icon="'copy'" class-name="'mr-0.5'"></pr-icon>Copy </span>
|
||||
<span id="copyValueNotification_{{ $index }}" style="display: none; color: #23ae89; margin-left: 5px" class="small">
|
||||
<pr-icon icon="'check'"></pr-icon> copied
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="ctrl.configuration.Used">
|
||||
<div class="col-sm-12">
|
||||
<kubernetes-integrated-applications-datatable
|
||||
dataset="ctrl.configuration.Applications"
|
||||
table-key="kubernetes.configurations.applications"
|
||||
order-by="Name"
|
||||
refresh-callback="ctrl.getApplications"
|
||||
title-text="Applications using this ConfigMap"
|
||||
title-icon="svg-laptopcode"
|
||||
>
|
||||
</kubernetes-integrated-applications-datatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesConfigurationView', {
|
||||
templateUrl: './configuration.html',
|
||||
controller: 'KubernetesConfigurationController',
|
||||
angular.module('portainer.kubernetes').component('kubernetesConfigMapView', {
|
||||
templateUrl: './configMap.html',
|
||||
controller: 'KubernetesConfigMapController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
$transition$: '<',
|
|
@ -0,0 +1,304 @@
|
|||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configuration/formvalues';
|
||||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
|
||||
import KubernetesConfigurationConverter from 'Kubernetes/converters/configuration';
|
||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { isConfigurationFormValid } from '../../validation';
|
||||
|
||||
class KubernetesConfigMapController {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$async,
|
||||
$state,
|
||||
$window,
|
||||
clipboard,
|
||||
EndpointProvider,
|
||||
Notifications,
|
||||
LocalStorage,
|
||||
Authentication,
|
||||
KubernetesConfigurationService,
|
||||
KubernetesConfigMapService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesEventService
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.clipboard = clipboard;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.Notifications = Notifications;
|
||||
this.LocalStorage = LocalStorage;
|
||||
this.Authentication = Authentication;
|
||||
this.KubernetesConfigurationService = KubernetesConfigurationService;
|
||||
this.KubernetesConfigMapService = KubernetesConfigMapService;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
this.KubernetesEventService = KubernetesEventService;
|
||||
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getConfigurationAsync = this.getConfigurationAsync.bind(this);
|
||||
this.getEvents = this.getEvents.bind(this);
|
||||
this.getEventsAsync = this.getEventsAsync.bind(this);
|
||||
this.getApplications = this.getApplications.bind(this);
|
||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
||||
this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this);
|
||||
this.updateConfiguration = this.updateConfiguration.bind(this);
|
||||
this.updateConfigurationAsync = this.updateConfigurationAsync.bind(this);
|
||||
}
|
||||
|
||||
isSystemNamespace() {
|
||||
return KubernetesNamespaceHelper.isSystemNamespace(this.configuration.Namespace);
|
||||
}
|
||||
|
||||
isSystemConfig() {
|
||||
return this.isSystemNamespace();
|
||||
}
|
||||
|
||||
selectTab(index) {
|
||||
this.LocalStorage.storeActiveTab('configuration', index);
|
||||
}
|
||||
|
||||
showEditor() {
|
||||
this.state.showEditorTab = true;
|
||||
this.selectTab(2);
|
||||
}
|
||||
|
||||
copyConfigurationValue(idx) {
|
||||
this.clipboard.copyText(this.formValues.Data[idx].Value);
|
||||
$('#copyValueNotification_' + idx)
|
||||
.show()
|
||||
.fadeOut(2500);
|
||||
}
|
||||
|
||||
isFormValid() {
|
||||
const [isValid] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// TODO: refactor
|
||||
// It looks like we're still doing a create/delete process but we've decided to get rid of this
|
||||
// approach.
|
||||
async updateConfigurationAsync() {
|
||||
try {
|
||||
this.state.actionInProgress = true;
|
||||
if (
|
||||
this.formValues.Kind !== this.configuration.Kind ||
|
||||
this.formValues.ResourcePool.Namespace.Name !== this.configuration.Namespace ||
|
||||
this.formValues.Name !== this.configuration.Name
|
||||
) {
|
||||
await this.KubernetesConfigurationService.create(this.formValues);
|
||||
await this.KubernetesConfigurationService.delete(this.configuration);
|
||||
this.Notifications.success('Success', `ConfigMap successfully updated`);
|
||||
this.$state.go(
|
||||
'kubernetes.configurations.configmap',
|
||||
{
|
||||
namespace: this.formValues.ResourcePool.Namespace.Name,
|
||||
name: this.formValues.Name,
|
||||
},
|
||||
{ reload: true }
|
||||
);
|
||||
} else {
|
||||
await this.KubernetesConfigurationService.update(this.formValues, this.configuration);
|
||||
this.Notifications.success('Success', `ConfigMap successfully updated`);
|
||||
this.$state.reload(this.$state.current);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, `Unable to update ConfigMap`);
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateConfiguration() {
|
||||
if (this.configuration.Used) {
|
||||
confirmUpdate(
|
||||
`The changes will be propagated to ${this.configuration.Applications.length} running ${pluralize(
|
||||
this.configuration.Applications.length,
|
||||
'application'
|
||||
)}. Are you sure you want to update this ConfigMap?`,
|
||||
(confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.updateConfigurationAsync);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return this.$async(this.updateConfigurationAsync);
|
||||
}
|
||||
}
|
||||
|
||||
async getConfigurationAsync() {
|
||||
try {
|
||||
this.state.configurationLoading = true;
|
||||
const name = this.$transition$.params().name;
|
||||
const namespace = this.$transition$.params().namespace;
|
||||
try {
|
||||
const configMap = await this.KubernetesConfigMapService.get(namespace, name);
|
||||
this.configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap);
|
||||
this.formValues.Data = configMap.Data;
|
||||
} catch (err) {
|
||||
if (err.status === 403) {
|
||||
this.$state.go('kubernetes.configurations', { tab: 'configmaps' });
|
||||
throw new Error('Not authorized to edit ConfigMap');
|
||||
}
|
||||
}
|
||||
|
||||
this.formValues.ResourcePool = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.configuration.Namespace);
|
||||
this.formValues.Id = this.configuration.Id;
|
||||
this.formValues.Name = this.configuration.Name;
|
||||
this.formValues.Type = this.configuration.Type;
|
||||
this.formValues.Kind = this.configuration.Kind;
|
||||
this.oldDataYaml = this.formValues.DataYaml;
|
||||
|
||||
return this.configuration;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve ConfigMap');
|
||||
} finally {
|
||||
this.state.configurationLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getConfiguration() {
|
||||
return this.$async(this.getConfigurationAsync);
|
||||
}
|
||||
|
||||
async getApplicationsAsync(namespace) {
|
||||
try {
|
||||
this.state.applicationsLoading = true;
|
||||
const applications = await this.KubernetesApplicationService.get(namespace);
|
||||
this.configuration.Applications = KubernetesConfigurationHelper.getUsingApplications(this.configuration, applications);
|
||||
KubernetesConfigurationHelper.setConfigurationUsed(this.configuration);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
||||
} finally {
|
||||
this.state.applicationsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getApplications(namespace) {
|
||||
return this.$async(this.getApplicationsAsync, namespace);
|
||||
}
|
||||
|
||||
hasEventWarnings() {
|
||||
return this.state.eventWarningCount;
|
||||
}
|
||||
|
||||
async getEventsAsync(namespace) {
|
||||
try {
|
||||
this.state.eventsLoading = true;
|
||||
this.events = await this.KubernetesEventService.get(namespace);
|
||||
this.events = _.filter(this.events, (event) => event.Involved.uid === this.configuration.Id);
|
||||
this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
|
||||
} catch (err) {
|
||||
this.Notifications('Failure', err, 'Unable to retrieve events');
|
||||
} finally {
|
||||
this.state.eventsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getEvents(namespace) {
|
||||
return this.$async(this.getEventsAsync, namespace);
|
||||
}
|
||||
|
||||
async getConfigurationsAsync() {
|
||||
try {
|
||||
this.configurations = await this.KubernetesConfigurationService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve configurations');
|
||||
}
|
||||
}
|
||||
|
||||
getConfigurations() {
|
||||
return this.$async(this.getConfigurationsAsync);
|
||||
}
|
||||
|
||||
tagUsedDataKeys() {
|
||||
const configName = this.$transition$.params().name;
|
||||
const usedDataKeys = _.uniq(
|
||||
this.configuration.Applications.flatMap((app) =>
|
||||
app.Env.filter((e) => e.valueFrom && e.valueFrom.configMapKeyRef && e.valueFrom.configMapKeyRef.name === configName).map((e) => e.name)
|
||||
)
|
||||
);
|
||||
|
||||
this.formValues.Data = this.formValues.Data.map((variable) => {
|
||||
if (!usedDataKeys.includes(variable.Key)) {
|
||||
return variable;
|
||||
}
|
||||
|
||||
return { ...variable, Used: true };
|
||||
});
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) {
|
||||
return confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
try {
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
configurationLoading: true,
|
||||
applicationsLoading: true,
|
||||
eventsLoading: true,
|
||||
showEditorTab: false,
|
||||
viewReady: false,
|
||||
eventWarningCount: 0,
|
||||
activeTab: 0,
|
||||
currentName: this.$state.$current.name,
|
||||
isDataValid: true,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('configuration');
|
||||
|
||||
this.formValues = new KubernetesConfigurationFormValues();
|
||||
|
||||
this.resourcePools = await this.KubernetesResourcePoolService.get();
|
||||
|
||||
const configuration = await this.getConfiguration();
|
||||
if (configuration) {
|
||||
await this.getApplications(this.configuration.Namespace);
|
||||
await this.getEvents(this.configuration.Namespace);
|
||||
await this.getConfigurations();
|
||||
}
|
||||
|
||||
this.tagUsedDataKeys();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
if (this.state.currentName !== this.$state.$current.name) {
|
||||
this.LocalStorage.storeActiveTab('configuration', 0);
|
||||
}
|
||||
this.state.isEditorDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesConfigMapController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesConfigMapController', KubernetesConfigMapController);
|
|
@ -1,17 +0,0 @@
|
|||
<page-header ng-if="ctrl.state.viewReady" title="'ConfigMaps & Secrets list'" breadcrumbs="['ConfigMaps & Secrets']" reload="true"></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<kubernetes-configurations-datatable
|
||||
dataset="ctrl.configurations"
|
||||
table-key="kubernetes.configurations"
|
||||
order-by="Name"
|
||||
refresh-callback="ctrl.refreshCallback"
|
||||
remove-action="ctrl.removeAction"
|
||||
></kubernetes-configurations-datatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +0,0 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesConfigurationsView', {
|
||||
templateUrl: './configurations.html',
|
||||
controller: 'KubernetesConfigurationsController',
|
||||
controllerAs: 'ctrl',
|
||||
});
|
|
@ -1,239 +0,0 @@
|
|||
<page-header
|
||||
ng-if="ctrl.state.viewReady"
|
||||
title="'Create ConfigMap or Secret'"
|
||||
breadcrumbs="[{ label:'ConfigMaps and Secrets', link:'kubernetes.configurations' }, 'Create a ConfigMap or Secret']"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
|
||||
<!-- resource-pool -->
|
||||
<div class="form-group" ng-if="ctrl.formValues.ResourcePool">
|
||||
<label for="resource-pool-selector" class="col-sm-3 col-lg-2 control-label text-left">Namespace</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<select
|
||||
class="form-control"
|
||||
id="resource-pool-selector"
|
||||
ng-model="ctrl.formValues.ResourcePool"
|
||||
ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools"
|
||||
ng-change="ctrl.onResourcePoolSelectionChange()"
|
||||
data-cy="k8sConfigCreate-namespaceDropdown"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool">
|
||||
<div class="col-sm-12 small text-warning vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. 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 vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
You do not have access to any namespace. Contact your administrator to get access to a namespace.
|
||||
</div>
|
||||
</div>
|
||||
<!-- !resource-pool -->
|
||||
|
||||
<!-- name -->
|
||||
<div class="form-group">
|
||||
<label for="configuration_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
||||
<div class="col-sm-8 col-lg-9 mb-0">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="configuration_name"
|
||||
ng-model="ctrl.formValues.Name"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/"
|
||||
ng-change="ctrl.onChangeName()"
|
||||
placeholder="my-configmap-or-secret"
|
||||
auto-focus
|
||||
required
|
||||
data-cy="k8sConfigCreate-nameInput"
|
||||
/>
|
||||
<div ng-show="kubernetesConfigurationCreationForm.configuration_name.$invalid || ctrl.state.alreadyExist">
|
||||
<div class="help-block small text-warning">
|
||||
<div ng-messages="kubernetesConfigurationCreationForm.configuration_name.$error">
|
||||
<p ng-message="required" class="vertical-center"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p>
|
||||
<p ng-message="pattern" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" class="vertical-center"></pr-icon> This field must consist of lower case alphanumeric characters, '-' or
|
||||
'.', and contain at most 63 characters, and must start and end with an alphanumeric character.</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.alreadyExist" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> A configuration with the same name already exists inside the selected namespace.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name -->
|
||||
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.formValues.ResourcePool">
|
||||
<div class="col-sm-12 form-section-title"> Kind </div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted"> Select the kind of data that you want to save. </div>
|
||||
</div>
|
||||
|
||||
<box-selector options="ctrl.typeOptions" value="ctrl.formValues.Kind" on-change="(ctrl.onChangeKind)" radio-name="'Kind'" slim="true"> </box-selector>
|
||||
|
||||
<div ng-if="ctrl.formValues.Kind === ctrl.KubernetesConfigurationKinds.SECRET">
|
||||
<div class="col-sm-12 form-section-title"> Information </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>
|
||||
More information about types of secret can be found in the official
|
||||
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">kubernetes documentation</a>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="configuration_data_type" class="col-sm-3 col-lg-2 control-label text-left">Type</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<select
|
||||
class="form-control"
|
||||
id="configuration_data_type"
|
||||
ng-model="ctrl.formValues.Type"
|
||||
ng-options="value.value as value.name for (name, value) in ctrl.KubernetesSecretTypeOptions"
|
||||
ng-change="ctrl.onSecretTypeChange()"
|
||||
></select>
|
||||
|
||||
<div class="col-sm-3 col-lg-2"></div>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value" class="col-sm-12 small text-warning vertical-center pt-5">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
<span
|
||||
>You should only create a service account token Secret object if you can't use the TokenRequest API to obtain a token, and the security exposure of persisting
|
||||
a non-expiring token credential in a readable API object is acceptable to you. <br />See
|
||||
<a href="https://kubernetes.io/docs/concepts/configuration/secret/#service-account-token-secrets" target="_blank">service account token secrets</a> in the
|
||||
kubernetes documentation.</span
|
||||
>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.DOCKERCFG.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>Ensure the Secret data field contains a <code>.dockercfg</code> key whose value is content of a legacy <code>~/.dockercfg</code> file.</span>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>Ensure the Secret data field contains a <code>.dockerconfigjson</code> key whose value is content of a <code>~/.docker/config.json</code> file.</span>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.TLS.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>Ensure the Secret data field contains a <code>tls.key</code> key and a <code>tls.crt</code> key.</span>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span
|
||||
>Ensure the Secret data field contains a <code>token-id</code> key and a <code>token-secret</code> key. See
|
||||
<a href="https://kubernetes.io/docs/concepts/configuration/secret/#bootstrap-token-secrets" target="_blank">bootstrap token secrets</a> in the kubernetes
|
||||
documentation for optional keys.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.CUSTOM.value">
|
||||
<label for="configuration_data_customtype" class="col-sm-3 col-lg-2 control-label required text-left">Custom Type</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<input
|
||||
type="text"
|
||||
name="custom_type"
|
||||
class="form-control"
|
||||
id="configuration_data_customtype"
|
||||
ng-model="ctrl.formValues.customType"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/"
|
||||
required
|
||||
/>
|
||||
<div ng-show="kubernetesConfigurationCreationForm.custom_type.$invalid">
|
||||
<div class="help-block small text-warning">
|
||||
<div ng-messages="kubernetesConfigurationCreationForm.custom_type.$error">
|
||||
<p ng-message="required" class="vertical-center"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p>
|
||||
<p ng-message="pattern" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" class="vertical-center"></pr-icon> This field must consist of lower case alphanumeric characters, '-'
|
||||
or '.', and contain at most 63 characters, and must start and end with an alphanumeric character.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value">
|
||||
<label for="service_account" class="col-sm-3 col-lg-2 control-label required text-left">Service Account</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<select
|
||||
class="form-control"
|
||||
id="service_account"
|
||||
ng-selected="$first"
|
||||
ng-model="ctrl.formValues.ServiceAccountName"
|
||||
ng-options="value.metadata.name as value.metadata.name for (name, value) in ctrl.availableServiceAccounts"
|
||||
data-cy="k8sConfigCreate-serviceAccountDropdown"
|
||||
ng-change="ctrl.onChangeServiceAccount()"
|
||||
required
|
||||
></select>
|
||||
<div class="help-block small text-warning" ng-messages="kubernetesConfigurationCreationForm.service_account.$error">
|
||||
<p class="vertical-center" ng-message="required"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<kubernetes-configuration-data
|
||||
ng-if="ctrl.formValues"
|
||||
form-values="ctrl.formValues"
|
||||
is-docker-config="ctrl.state.isDockerConfig"
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="true"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
<div class="form-group" ng-if="ctrl.state.secretWarningMessage">
|
||||
<div class="col-sm-12 small text-warning vertical-center pt-5">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
<span>{{ ctrl.state.secretWarningMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view
|
||||
ng-if="!(!kubernetesConfigurationCreationForm.$valid || !ctrl.isFormValid() || ctrl.state.actionInProgress)"
|
||||
form-values="ctrl.formValues"
|
||||
></kubernetes-summary-view>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title" style="margin-top: 10px"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!kubernetesConfigurationCreationForm.$valid || !ctrl.isFormValid() || ctrl.state.actionInProgress || !ctrl.formValues.ResourcePool"
|
||||
ng-click="ctrl.createConfiguration()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sConfigCreate-CreateConfigButton"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Create {{ ctrl.formValues.Kind | kubernetesConfigurationKindText }}</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +0,0 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesCreateConfigurationView', {
|
||||
templateUrl: './createConfiguration.html',
|
||||
controller: 'KubernetesCreateConfigurationController',
|
||||
controllerAs: 'ctrl',
|
||||
});
|
|
@ -0,0 +1,229 @@
|
|||
<page-header
|
||||
ng-if="ctrl.state.viewReady"
|
||||
title="'Create Secret'"
|
||||
breadcrumbs="[{ label:'ConfigMaps and Secrets', link:'kubernetes.configurations', linkParams:{ tab: 'secrets' } }, 'Create a Secret']"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
|
||||
<!-- resource-pool -->
|
||||
<div class="form-group" ng-if="ctrl.formValues.ResourcePool">
|
||||
<label for="resource-pool-selector" class="col-sm-3 col-lg-2 control-label text-left">Namespace</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<select
|
||||
class="form-control"
|
||||
id="resource-pool-selector"
|
||||
ng-model="ctrl.formValues.ResourcePool"
|
||||
ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools"
|
||||
ng-change="ctrl.onResourcePoolSelectionChange()"
|
||||
data-cy="k8sConfigCreate-namespaceDropdown"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool">
|
||||
<div class="col-sm-12 small text-warning vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. 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 vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
You do not have access to any namespace. Contact your administrator to get access to a namespace.
|
||||
</div>
|
||||
</div>
|
||||
<!-- !resource-pool -->
|
||||
|
||||
<!-- name -->
|
||||
<div class="form-group">
|
||||
<label for="configuration_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
||||
<div class="col-sm-8 col-lg-9 mb-0">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="configuration_name"
|
||||
ng-model="ctrl.formValues.Name"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/"
|
||||
ng-change="ctrl.onChangeName()"
|
||||
placeholder="my-secret"
|
||||
auto-focus
|
||||
required
|
||||
data-cy="k8sConfigCreate-nameInput"
|
||||
/>
|
||||
<div ng-show="kubernetesConfigurationCreationForm.configuration_name.$invalid || ctrl.state.alreadyExist">
|
||||
<div class="help-block small text-warning">
|
||||
<div ng-messages="kubernetesConfigurationCreationForm.configuration_name.$error">
|
||||
<p ng-message="required" class="vertical-center"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p>
|
||||
<p ng-message="pattern" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" class="vertical-center"></pr-icon> This field must consist of lower case alphanumeric characters, '-' or
|
||||
'.', and contain at most 63 characters, and must start and end with an alphanumeric character.</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.alreadyExist" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> A configuration with the same name already exists inside the selected namespace.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name -->
|
||||
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.formValues.ResourcePool">
|
||||
<div class="col-sm-12 form-section-title"> Information </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>
|
||||
More information about types of secret can be found in the official
|
||||
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">kubernetes documentation</a>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="configuration_data_type" class="col-sm-3 col-lg-2 control-label text-left">Type</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<select
|
||||
class="form-control"
|
||||
id="configuration_data_type"
|
||||
ng-model="ctrl.formValues.Type"
|
||||
ng-options="value.value as value.name for (name, value) in ctrl.KubernetesSecretTypeOptions"
|
||||
ng-change="ctrl.onSecretTypeChange()"
|
||||
></select>
|
||||
|
||||
<div class="col-sm-3 col-lg-2"></div>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value" class="col-sm-12 small text-warning vertical-center pt-5">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
<span
|
||||
>You should only create a service account token Secret object if you can't use the TokenRequest API to obtain a token, and the security exposure of persisting a
|
||||
non-expiring token credential in a readable API object is acceptable to you. <br />See
|
||||
<a href="https://kubernetes.io/docs/concepts/configuration/secret/#service-account-token-secrets" target="_blank">service account token secrets</a> in the
|
||||
kubernetes documentation.</span
|
||||
>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.DOCKERCFG.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>Ensure the Secret data field contains a <code>.dockercfg</code> key whose value is content of a legacy <code>~/.dockercfg</code> file.</span>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>Ensure the Secret data field contains a <code>.dockerconfigjson</code> key whose value is content of a <code>~/.docker/config.json</code> file.</span>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.TLS.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>Ensure the Secret data field contains a <code>tls.key</code> key and a <code>tls.crt</code> key.</span>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value" class="col-sm-12 small text-muted vertical-center pt-5">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span
|
||||
>Ensure the Secret data field contains a <code>token-id</code> key and a <code>token-secret</code> key. See
|
||||
<a href="https://kubernetes.io/docs/concepts/configuration/secret/#bootstrap-token-secrets" target="_blank">bootstrap token secrets</a> in the kubernetes
|
||||
documentation for optional keys.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.CUSTOM.value">
|
||||
<label for="configuration_data_customtype" class="col-sm-3 col-lg-2 control-label required text-left">Custom Type</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<input
|
||||
type="text"
|
||||
name="custom_type"
|
||||
class="form-control"
|
||||
id="configuration_data_customtype"
|
||||
ng-model="ctrl.formValues.customType"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/"
|
||||
required
|
||||
/>
|
||||
<div ng-show="kubernetesConfigurationCreationForm.custom_type.$invalid">
|
||||
<div class="help-block small text-warning">
|
||||
<div ng-messages="kubernetesConfigurationCreationForm.custom_type.$error">
|
||||
<p ng-message="required" class="vertical-center"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p>
|
||||
<p ng-message="pattern" class="vertical-center"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" class="vertical-center"></pr-icon> This field must consist of lower case alphanumeric characters, '-'
|
||||
or '.', and contain at most 63 characters, and must start and end with an alphanumeric character.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value">
|
||||
<label for="service_account" class="col-sm-3 col-lg-2 control-label required text-left">Service Account</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<select
|
||||
class="form-control"
|
||||
id="service_account"
|
||||
ng-selected="$first"
|
||||
ng-model="ctrl.formValues.ServiceAccountName"
|
||||
ng-options="value.metadata.name as value.metadata.name for (name, value) in ctrl.availableServiceAccounts"
|
||||
data-cy="k8sConfigCreate-serviceAccountDropdown"
|
||||
ng-change="ctrl.onChangeServiceAccount()"
|
||||
required
|
||||
></select>
|
||||
<div class="help-block small text-warning" ng-messages="kubernetesConfigurationCreationForm.service_account.$error">
|
||||
<p class="vertical-center" ng-message="required"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<kubernetes-configuration-data
|
||||
ng-if="ctrl.formValues"
|
||||
form-values="ctrl.formValues"
|
||||
is-docker-config="ctrl.state.isDockerConfig"
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="true"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
<div class="form-group" ng-if="ctrl.state.secretWarningMessage">
|
||||
<div class="col-sm-12 small text-warning vertical-center pt-5">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
<span>{{ ctrl.state.secretWarningMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view
|
||||
ng-if="!(!kubernetesConfigurationCreationForm.$valid || !ctrl.isFormValid() || ctrl.state.actionInProgress)"
|
||||
form-values="ctrl.formValues"
|
||||
></kubernetes-summary-view>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title" style="margin-top: 10px"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!kubernetesConfigurationCreationForm.$valid || !ctrl.isFormValid() || ctrl.state.actionInProgress || !ctrl.formValues.ResourcePool"
|
||||
ng-click="ctrl.createConfiguration()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sConfigCreate-CreateConfigButton"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Create {{ ctrl.formValues.Kind | kubernetesConfigurationKindText }}</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesCreateSecretView', {
|
||||
templateUrl: './createSecret.html',
|
||||
controller: 'KubernetesCreateSecretController',
|
||||
controllerAs: 'ctrl',
|
||||
});
|
|
@ -8,9 +8,9 @@ import { getServiceAccounts } from 'Kubernetes/rest/serviceAccount';
|
|||
import { typeOptions } from '@/react/kubernetes/configs/CreateView/options';
|
||||
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { isConfigurationFormValid } from '../validation';
|
||||
import { isConfigurationFormValid } from '../../validation';
|
||||
|
||||
class KubernetesCreateConfigurationController {
|
||||
class KubernetesCreateSecretController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $scope, $window, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) {
|
||||
this.$async = $async;
|
||||
|
@ -32,7 +32,6 @@ class KubernetesCreateConfigurationController {
|
|||
this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this);
|
||||
this.onResourcePoolSelectionChangeAsync = this.onResourcePoolSelectionChangeAsync.bind(this);
|
||||
this.onSecretTypeChange = this.onSecretTypeChange.bind(this);
|
||||
this.onChangeKind = this.onChangeKind.bind(this);
|
||||
}
|
||||
|
||||
onChangeName() {
|
||||
|
@ -43,30 +42,13 @@ class KubernetesCreateConfigurationController {
|
|||
this.state.alreadyExist = _.find(filteredConfigurations, (config) => config.Name === this.formValues.Name) !== undefined;
|
||||
}
|
||||
|
||||
onChangeKind(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.Kind = value;
|
||||
this.onChangeName();
|
||||
// if there is no data field, add one
|
||||
if (this.formValues.Data.length === 0) {
|
||||
this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry());
|
||||
}
|
||||
// if changing back to a secret, that is a service account token, remove the data field
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
this.onSecretTypeChange();
|
||||
} else {
|
||||
this.isDockerConfig = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onResourcePoolSelectionChangeAsync() {
|
||||
try {
|
||||
this.onChangeName();
|
||||
this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.formValues.ResourcePool.Namespace.Name);
|
||||
this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : '';
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable load service accounts');
|
||||
this.Notifications.error('Failure', err, 'Unable to load service accounts');
|
||||
}
|
||||
}
|
||||
onResourcePoolSelectionChange() {
|
||||
|
@ -140,11 +122,6 @@ class KubernetesCreateConfigurationController {
|
|||
}
|
||||
|
||||
async createConfigurationAsync() {
|
||||
let kind = 'ConfigMap';
|
||||
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
|
||||
kind = 'Secret';
|
||||
}
|
||||
|
||||
try {
|
||||
this.state.actionInProgress = true;
|
||||
this.formValues.ConfigurationOwner = this.Authentication.getUserDetails().username;
|
||||
|
@ -154,11 +131,11 @@ class KubernetesCreateConfigurationController {
|
|||
|
||||
await this.KubernetesConfigurationService.create(this.formValues);
|
||||
|
||||
this.Notifications.success('Success', `${kind} successfully created`);
|
||||
this.Notifications.success('Success', `Secret successfully created`);
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.configurations');
|
||||
this.$state.go('kubernetes.configurations', { tab: 'secrets' });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, `Unable to create ${kind}`);
|
||||
this.Notifications.error('Failure', err, `Unable to create secret`);
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
|
@ -172,7 +149,7 @@ class KubernetesCreateConfigurationController {
|
|||
try {
|
||||
this.configurations = await this.KubernetesConfigurationService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve ConfigMaps and Secrets');
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve Secrets');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,6 +175,7 @@ class KubernetesCreateConfigurationController {
|
|||
};
|
||||
|
||||
this.formValues = new KubernetesConfigurationFormValues();
|
||||
this.formValues.Kind = KubernetesConfigurationKinds.SECRET;
|
||||
this.formValues.Data = [new KubernetesConfigurationFormValuesEntry()];
|
||||
|
||||
try {
|
||||
|
@ -235,5 +213,5 @@ class KubernetesCreateConfigurationController {
|
|||
}
|
||||
}
|
||||
|
||||
export default KubernetesCreateConfigurationController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesCreateConfigurationController', KubernetesCreateConfigurationController);
|
||||
export default KubernetesCreateSecretController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesCreateSecretController', KubernetesCreateSecretController);
|
|
@ -1,6 +1,6 @@
|
|||
<page-header
|
||||
ng-if="ctrl.state.viewReady"
|
||||
title="'ConfigMap or Secret details'"
|
||||
title="'Secret details'"
|
||||
breadcrumbs="[
|
||||
{ label:'Namespaces', link:'kubernetes.resourcePools' },
|
||||
{
|
||||
|
@ -8,7 +8,7 @@
|
|||
link: 'kubernetes.resourcePools.resourcePool',
|
||||
linkParams:{ id: ctrl.configuration.Namespace }
|
||||
},
|
||||
{ label:'ConfigMaps and Secrets', link:'kubernetes.configurations' },
|
||||
{ label:'ConfigMaps and Secrets', link:'kubernetes.configurations', linkParams:{ tab: 'secrets' } },
|
||||
ctrl.configuration.Name,
|
||||
]"
|
||||
reload="true"
|
||||
|
@ -25,8 +25,8 @@
|
|||
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
|
||||
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)" data-cy="k8sConfigDetail-configTab">
|
||||
<uib-tab-heading>
|
||||
<pr-icon icon="'file-code'"></pr-icon>
|
||||
Configuration
|
||||
<pr-icon icon="'lock'"></pr-icon>
|
||||
Secret
|
||||
</uib-tab-heading>
|
||||
<div style="padding: 20px">
|
||||
<table class="table" data-cy="k8sConfigDetail-configTable">
|
||||
|
@ -45,12 +45,6 @@
|
|||
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace()">system</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="!pl-0">Kind</td>
|
||||
<td>
|
||||
{{ ctrl.configuration.Kind | kubernetesConfigurationKindText }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="ctrl.secretTypeName">
|
||||
<td class="!pl-0">Secret Type</td>
|
||||
<td>
|
||||
|
@ -88,7 +82,7 @@
|
|||
YAML
|
||||
</uib-tab-heading>
|
||||
<div class="px-5 !pt-5" ng-if="ctrl.state.showEditorTab">
|
||||
<kubernetes-yaml-inspector key="configuration-yaml" data="ctrl.configuration.Yaml"> </kubernetes-yaml-inspector>
|
||||
<kubernetes-yaml-inspector key="configuration-yaml" data="ctrl.configuration.Yaml"></kubernetes-yaml-inspector>
|
||||
</div>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
|
@ -184,7 +178,7 @@
|
|||
table-key="kubernetes.configurations.applications"
|
||||
order-by="Name"
|
||||
refresh-callback="ctrl.getApplications"
|
||||
title-text="Applications using this configuration"
|
||||
title-text="Applications using this secret"
|
||||
title-icon="svg-laptopcode"
|
||||
>
|
||||
</kubernetes-integrated-applications-datatable>
|
|
@ -0,0 +1,8 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesSecretView', {
|
||||
templateUrl: './secret.html',
|
||||
controller: 'KubernetesSecretController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
$transition$: '<',
|
||||
},
|
||||
});
|
|
@ -8,10 +8,12 @@ import KubernetesConfigurationConverter from 'Kubernetes/converters/configuratio
|
|||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
|
||||
import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { isConfigurationFormValid } from '../validation';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
class KubernetesConfigurationController {
|
||||
import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { isConfigurationFormValid } from '../../validation';
|
||||
|
||||
class KubernetesSecretController {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$async,
|
||||
|
@ -21,7 +23,6 @@ class KubernetesConfigurationController {
|
|||
Notifications,
|
||||
LocalStorage,
|
||||
KubernetesConfigurationService,
|
||||
KubernetesConfigMapService,
|
||||
KubernetesSecretService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesApplicationService,
|
||||
|
@ -39,7 +40,6 @@ class KubernetesConfigurationController {
|
|||
this.KubernetesEventService = KubernetesEventService;
|
||||
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
|
||||
this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions;
|
||||
this.KubernetesConfigMapService = KubernetesConfigMapService;
|
||||
this.KubernetesSecretService = KubernetesSecretService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
|
@ -48,7 +48,6 @@ class KubernetesConfigurationController {
|
|||
this.getEventsAsync = this.getEventsAsync.bind(this);
|
||||
this.getApplications = this.getApplications.bind(this);
|
||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
||||
this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this);
|
||||
this.updateConfiguration = this.updateConfiguration.bind(this);
|
||||
this.updateConfigurationAsync = this.updateConfigurationAsync.bind(this);
|
||||
}
|
||||
|
@ -87,11 +86,6 @@ class KubernetesConfigurationController {
|
|||
// It looks like we're still doing a create/delete process but we've decided to get rid of this
|
||||
// approach.
|
||||
async updateConfigurationAsync() {
|
||||
let kind = 'ConfigMap';
|
||||
if (this.formValues.Kind === KubernetesConfigurationKinds.SECRET) {
|
||||
kind = 'Secret';
|
||||
}
|
||||
|
||||
try {
|
||||
this.state.actionInProgress = true;
|
||||
if (
|
||||
|
@ -101,9 +95,9 @@ class KubernetesConfigurationController {
|
|||
) {
|
||||
await this.KubernetesConfigurationService.create(this.formValues);
|
||||
await this.KubernetesConfigurationService.delete(this.configuration);
|
||||
this.Notifications.success('Success', `${kind} successfully updated`);
|
||||
this.Notifications.success('Success', `Secret successfully updated`);
|
||||
this.$state.go(
|
||||
'kubernetes.configurations.configuration',
|
||||
'kubernetes.secrets.secret',
|
||||
{
|
||||
namespace: this.formValues.ResourcePool.Namespace.Name,
|
||||
name: this.formValues.Name,
|
||||
|
@ -112,11 +106,11 @@ class KubernetesConfigurationController {
|
|||
);
|
||||
} else {
|
||||
await this.KubernetesConfigurationService.update(this.formValues, this.configuration);
|
||||
this.Notifications.success('Success', `${kind} successfully updated`);
|
||||
this.Notifications.success('Success', `Secret successfully updated`);
|
||||
this.$state.reload(this.$state.current);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, `Unable to update ${kind}`);
|
||||
this.Notifications.error('Failure', err, `Unable to update Secret`);
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
|
@ -124,10 +118,11 @@ class KubernetesConfigurationController {
|
|||
|
||||
updateConfiguration() {
|
||||
if (this.configuration.Used) {
|
||||
const plural = this.configuration.Applications.length > 1 ? 's' : '';
|
||||
const thisorthese = this.configuration.Applications.length > 1 ? 'these' : 'this';
|
||||
confirmUpdate(
|
||||
`The changes will be propagated to ${this.configuration.Applications.length} running application${plural}. Are you sure you want to update ${thisorthese} ConfigMap${plural} or Secret${plural}?`,
|
||||
`The changes will be propagated to ${this.configuration.Applications.length} running ${pluralize(
|
||||
this.configuration.Applications.length,
|
||||
'application'
|
||||
)}. Are you sure you want to update this Secret?`,
|
||||
(confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.updateConfigurationAsync);
|
||||
|
@ -144,18 +139,15 @@ class KubernetesConfigurationController {
|
|||
this.state.configurationLoading = true;
|
||||
const name = this.$transition$.params().name;
|
||||
const namespace = this.$transition$.params().namespace;
|
||||
const [configMap, secret] = await Promise.allSettled([this.KubernetesConfigMapService.get(namespace, name), this.KubernetesSecretService.get(namespace, name)]);
|
||||
if (secret.status === 'rejected' && secret.reason.err.status === 403) {
|
||||
this.$state.go('kubernetes.configurations');
|
||||
throw new Error('Not authorized to edit secret');
|
||||
}
|
||||
if (secret.status === 'fulfilled') {
|
||||
this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret.value);
|
||||
this.formValues.Data = secret.value.Data;
|
||||
// this.formValues.ServiceAccountName = secret.value.ServiceAccountName;
|
||||
} else {
|
||||
this.configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap.value);
|
||||
this.formValues.Data = configMap.value.Data;
|
||||
try {
|
||||
const secret = await this.KubernetesSecretService.get(namespace, name);
|
||||
this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret);
|
||||
this.formValues.Data = secret.Data;
|
||||
} catch (err) {
|
||||
if (err.status === 403) {
|
||||
this.$state.go('kubernetes.configurations', { tab: 'secrets' });
|
||||
throw new Error('Not authorized to edit secret');
|
||||
}
|
||||
}
|
||||
this.formValues.ResourcePool = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.configuration.Namespace);
|
||||
this.formValues.Id = this.configuration.Id;
|
||||
|
@ -166,7 +158,7 @@ class KubernetesConfigurationController {
|
|||
|
||||
return this.configuration;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve configuration');
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve secret');
|
||||
} finally {
|
||||
this.state.configurationLoading = false;
|
||||
}
|
||||
|
@ -214,18 +206,6 @@ class KubernetesConfigurationController {
|
|||
return this.$async(this.getEventsAsync, namespace);
|
||||
}
|
||||
|
||||
async getConfigurationsAsync() {
|
||||
try {
|
||||
this.configurations = await this.KubernetesConfigurationService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve configurations');
|
||||
}
|
||||
}
|
||||
|
||||
getConfigurations() {
|
||||
return this.$async(this.getConfigurationsAsync);
|
||||
}
|
||||
|
||||
tagUsedDataKeys() {
|
||||
const configName = this.$transition$.params().name;
|
||||
const usedDataKeys = _.uniq(
|
||||
|
@ -276,7 +256,6 @@ class KubernetesConfigurationController {
|
|||
if (configuration) {
|
||||
await this.getApplications(this.configuration.Namespace);
|
||||
await this.getEvents(this.configuration.Namespace);
|
||||
await this.getConfigurations();
|
||||
}
|
||||
|
||||
// after loading the configuration, check if it is a docker config secret type
|
||||
|
@ -325,5 +304,5 @@ class KubernetesConfigurationController {
|
|||
}
|
||||
}
|
||||
|
||||
export default KubernetesConfigurationController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesConfigurationController', KubernetesConfigurationController);
|
||||
export default KubernetesSecretController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesSecretController', KubernetesSecretController);
|
|
@ -284,6 +284,11 @@ class KubernetesDeployController {
|
|||
this.Notifications.success('Success', 'Request to deploy manifest successfully submitted');
|
||||
this.state.isEditorDirty = false;
|
||||
|
||||
if (this.$state.params.referrer && this.$state.params.tab) {
|
||||
this.$state.go(this.$state.params.referrer, { tab: this.$state.params.tab });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$state.params.referrer) {
|
||||
this.$state.go(this.$state.params.referrer);
|
||||
return;
|
||||
|
|
|
@ -16,6 +16,12 @@ export function isFulfilled<T>(
|
|||
return result.status === 'fulfilled';
|
||||
}
|
||||
|
||||
export function isRejected<T>(
|
||||
result: PromiseSettledResult<T>
|
||||
): result is PromiseRejectedResult {
|
||||
return result.status === 'rejected';
|
||||
}
|
||||
|
||||
export function getFulfilledResults<T>(
|
||||
results: Array<PromiseSettledResult<T>>
|
||||
) {
|
||||
|
|
|
@ -127,6 +127,7 @@ export const ngModule = angular
|
|||
'type',
|
||||
'value',
|
||||
'to',
|
||||
'params',
|
||||
'children',
|
||||
'pluralType',
|
||||
'isLoading',
|
||||
|
|
|
@ -49,6 +49,13 @@ export function agentInterceptor(config: AxiosRequestConfig) {
|
|||
|
||||
axios.interceptors.request.use(agentInterceptor);
|
||||
|
||||
/**
|
||||
* Parses an Axios error and returns a PortainerError.
|
||||
* @param err The original error.
|
||||
* @param msg An optional error message to prepend.
|
||||
* @param parseError A function to parse AxiosErrors. Defaults to defaultErrorParser.
|
||||
* @returns A PortainerError with the parsed error message and details.
|
||||
*/
|
||||
export function parseAxiosError(
|
||||
err: Error,
|
||||
msg = '',
|
||||
|
@ -70,7 +77,7 @@ export function parseAxiosError(
|
|||
return new PortainerError(resultMsg, resultErr);
|
||||
}
|
||||
|
||||
function defaultErrorParser(axiosError: AxiosError) {
|
||||
export function defaultErrorParser(axiosError: AxiosError) {
|
||||
const message = axiosError.response?.data.message || '';
|
||||
const details = axiosError.response?.data.details || message;
|
||||
const error = new Error(message);
|
||||
|
|
|
@ -22,7 +22,7 @@ export function Badge({ type, className, children }: PropsWithChildren<Props>) {
|
|||
);
|
||||
}
|
||||
|
||||
// classes in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
|
||||
// the classes are typed in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
|
||||
function getClasses(type: BadgeType | undefined) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
|
|
|
@ -14,6 +14,7 @@ interface Props extends IconProps {
|
|||
isRefetching?: boolean;
|
||||
value?: number;
|
||||
to?: string;
|
||||
params?: object;
|
||||
children?: ReactNode;
|
||||
dataCy?: string;
|
||||
}
|
||||
|
@ -26,6 +27,7 @@ export function DashboardItem({
|
|||
isRefetching,
|
||||
value,
|
||||
to,
|
||||
params,
|
||||
children,
|
||||
dataCy,
|
||||
}: Props) {
|
||||
|
@ -41,7 +43,7 @@ export function DashboardItem({
|
|||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'text-muted absolute top-2 right-2 flex items-center transition-opacity',
|
||||
'text-muted absolute top-2 right-2 flex items-center text-xs transition-opacity',
|
||||
isRefetching ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
|
@ -50,7 +52,7 @@ export function DashboardItem({
|
|||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'text-muted absolute top-2 right-2 flex items-center transition-opacity',
|
||||
'text-muted absolute top-2 right-2 flex items-center text-xs transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
|
@ -101,7 +103,7 @@ export function DashboardItem({
|
|||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className="!no-underline">
|
||||
<Link to={to} className="!no-underline" params={params}>
|
||||
{Item}
|
||||
</Link>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { RawParams } from '@uirouter/react';
|
||||
import clsx from 'clsx';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export interface Tab {
|
||||
name: string;
|
||||
icon: ReactNode;
|
||||
widget: ReactNode;
|
||||
selectedTabParam: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentTabIndex: number;
|
||||
tabs: Tab[];
|
||||
}
|
||||
|
||||
export function WidgetTabs({ currentTabIndex, tabs }: Props) {
|
||||
// ensure that the selectedTab param is always valid
|
||||
const invalidQueryParamValue = tabs.every(
|
||||
(tab) => encodeURIComponent(tab.selectedTabParam) !== tab.selectedTabParam
|
||||
);
|
||||
|
||||
if (invalidQueryParamValue) {
|
||||
throw new Error('Invalid query param value for tab');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12 !mb-0">
|
||||
<div className="pl-2">
|
||||
{tabs.map(({ name, icon }, index) => (
|
||||
<Link
|
||||
to="."
|
||||
params={{ tab: tabs[index].selectedTabParam }}
|
||||
key={index}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2',
|
||||
currentTabIndex === index
|
||||
? 'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6'
|
||||
: 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
{name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// findSelectedTabIndex returns the index of the tab, or 0 if none is selected
|
||||
export function findSelectedTabIndex(
|
||||
{ params }: { params: RawParams },
|
||||
tabs: Tab[]
|
||||
) {
|
||||
const selectedTabParam = params.tab || tabs[0].selectedTabParam;
|
||||
const currentTabIndex = tabs.findIndex(
|
||||
(tab) => tab.selectedTabParam === selectedTabParam
|
||||
);
|
||||
return currentTabIndex || 0;
|
||||
}
|
|
@ -13,6 +13,7 @@ export function createSelectColumn<T>(): ColumnDef<T> {
|
|||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
||||
/>
|
||||
),
|
||||
cell: ({ row, table }) => (
|
||||
|
@ -21,6 +22,7 @@ export function createSelectColumn<T>(): ColumnDef<T> {
|
|||
checked={row.getIsSelected()}
|
||||
indeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) {
|
||||
const { rows, rowsById } = table.getRowModel();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { EnvVar, Pod } from 'kubernetes-types/core/v1';
|
||||
import { Asterisk, File, Key } from 'lucide-react';
|
||||
import { Asterisk, File, FileCode, Key, Lock } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
@ -93,14 +93,14 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
|
|||
{envVar.valueFrom?.configMapKeyRef && (
|
||||
<span>
|
||||
<Link
|
||||
to="kubernetes.configurations.configuration"
|
||||
to="kubernetes.configmaps.configmap"
|
||||
params={{
|
||||
name: envVar.valueFrom.configMapKeyRef.name,
|
||||
namespace,
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Icon icon={File} className="!mr-1" />
|
||||
<Icon icon={FileCode} className="!mr-1" />
|
||||
{envVar.valueFrom.configMapKeyRef.name}
|
||||
</Link>
|
||||
</span>
|
||||
|
@ -108,14 +108,14 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
|
|||
{envVar.valueFrom?.secretKeyRef && (
|
||||
<span>
|
||||
<Link
|
||||
to="kubernetes.configurations.configuration"
|
||||
to="kubernetes.secrets.secret"
|
||||
params={{
|
||||
name: envVar.valueFrom.secretKeyRef.name,
|
||||
namespace,
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Icon icon={File} className="!mr-1" />
|
||||
<Icon icon={Lock} className="!mr-1" />
|
||||
{envVar.valueFrom.secretKeyRef.name}
|
||||
</Link>
|
||||
</span>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Pod } from 'kubernetes-types/core/v1';
|
|||
import { queryClient, withError } from '@/react-tools/react-query';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { getNamespaceServices } from '../ServicesView/service';
|
||||
import { getNamespaceServices } from '../services/service';
|
||||
|
||||
import {
|
||||
getApplicationsForCluster,
|
||||
|
@ -112,10 +112,10 @@ export function useApplicationsForCluster(
|
|||
) {
|
||||
return useQuery(
|
||||
queryKeys.applicationsForCluster(environemtId),
|
||||
() => namespaces && getApplicationsForCluster(environemtId, namespaces),
|
||||
() => getApplicationsForCluster(environemtId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve applications'),
|
||||
enabled: !!namespaces,
|
||||
enabled: !!namespaces?.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,11 +27,14 @@ import { appRevisionAnnotation } from './constants';
|
|||
|
||||
export async function getApplicationsForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces: string[]
|
||||
namespaceNames?: string[]
|
||||
) {
|
||||
try {
|
||||
if (!namespaceNames) {
|
||||
return [];
|
||||
}
|
||||
const applications = await Promise.all(
|
||||
namespaces.map((namespace) =>
|
||||
namespaceNames.map((namespace) =>
|
||||
getApplicationsForNamespace(environmentId, namespace)
|
||||
)
|
||||
);
|
||||
|
@ -74,7 +77,7 @@ async function getApplicationsForNamespace(
|
|||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to retrieve applications in namespace ${namespace}`
|
||||
`Unable to retrieve applications in namespace '${namespace}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -145,7 +148,7 @@ export async function getApplication(
|
|||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to retrieve application ${name} in namespace ${namespace}`
|
||||
`Unable to retrieve application ${name} in namespace '${namespace}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -193,7 +196,7 @@ export async function patchApplication(
|
|||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to patch application ${name} in namespace ${namespace}`
|
||||
`Unable to patch application ${name} in namespace '${namespace}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { Status } from 'kubernetes-types/meta/v1';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import {
|
||||
defaultErrorParser,
|
||||
parseAxiosError,
|
||||
} from '@/portainer/services/axios';
|
||||
|
||||
export function kubernetesErrorParser(axiosError: AxiosError) {
|
||||
const responseStatus = axiosError.response?.data as Status;
|
||||
const { message } = responseStatus;
|
||||
if (message) {
|
||||
return {
|
||||
error: new Error(message),
|
||||
details: message,
|
||||
};
|
||||
}
|
||||
return defaultErrorParser(axiosError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Axios error response from the Kubernetes API.
|
||||
* @param err The Axios error object.
|
||||
* @param msg An optional error message to prepend.
|
||||
* @returns An error object with an error message and details.
|
||||
*/
|
||||
export function parseKubernetesAxiosError(err: Error, msg = '') {
|
||||
return parseAxiosError(err, msg, kubernetesErrorParser);
|
||||
}
|
|
@ -1,17 +1,13 @@
|
|||
import { IngressClassList } from 'kubernetes-types/networking/v1';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
KubernetesApiListResponse,
|
||||
V1IngressClass,
|
||||
} from '@/react/kubernetes/services/types';
|
||||
|
||||
export async function getAllIngressClasses(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const {
|
||||
data: { items },
|
||||
} = await axios.get<KubernetesApiListResponse<V1IngressClass[]>>(
|
||||
urlBuilder(environmentId)
|
||||
);
|
||||
} = await axios.get<IngressClassList>(urlBuilder(environmentId));
|
||||
return items;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
|
|
|
@ -0,0 +1,196 @@
|
|||
import { useMemo } from 'react';
|
||||
import { FileCode, Plus, Trash2 } from 'lucide-react';
|
||||
import { ConfigMap } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import {
|
||||
Authorized,
|
||||
useAuthorizations,
|
||||
useCurrentUser,
|
||||
} from '@/react/hooks/useUser';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
|
||||
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import {
|
||||
useConfigMapsForCluster,
|
||||
useMutationDeleteConfigMaps,
|
||||
} from '../../configmap.service';
|
||||
import { IndexOptional } from '../../types';
|
||||
|
||||
import { getIsConfigMapInUse } from './utils';
|
||||
import { ConfigMapRowData } from './types';
|
||||
import { columns } from './columns';
|
||||
|
||||
const storageKey = 'k8sConfigMapsDatatable';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function ConfigMapsDatatable() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const readOnly = !useAuthorizations(['K8sConfigMapsW']);
|
||||
const { isAdmin } = useCurrentUser();
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(
|
||||
environmentId,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
const namespaceNames = Object.keys(namespaces || {});
|
||||
const { data: configMaps, ...configMapsQuery } = useConfigMapsForCluster(
|
||||
environmentId,
|
||||
namespaceNames,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
const { data: applications, ...applicationsQuery } =
|
||||
useApplicationsForCluster(environmentId, namespaceNames);
|
||||
|
||||
const filteredConfigMaps = useMemo(
|
||||
() =>
|
||||
configMaps?.filter(
|
||||
(configMap) =>
|
||||
(isAdmin && tableState.showSystemResources) ||
|
||||
!isSystemNamespace(configMap.metadata?.namespace ?? '')
|
||||
) || [],
|
||||
[configMaps, tableState, isAdmin]
|
||||
);
|
||||
const configMapRowData = useConfigMapRowData(
|
||||
filteredConfigMaps,
|
||||
applications ?? [],
|
||||
applicationsQuery.isLoading
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable<IndexOptional<ConfigMapRowData>>
|
||||
dataset={configMapRowData}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
isLoading={configMapsQuery.isLoading || namespacesQuery.isLoading}
|
||||
emptyContentLabel="No ConfigMaps found"
|
||||
title="ConfigMaps"
|
||||
titleIcon={FileCode}
|
||||
getRowId={(row) => row.metadata?.uid ?? ''}
|
||||
isRowSelectable={(row) =>
|
||||
!isSystemNamespace(row.original.metadata?.namespace ?? '')
|
||||
}
|
||||
disableSelect={readOnly}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<TableActions selectedItems={selectedRows} />
|
||||
)}
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<DefaultDatatableSettings
|
||||
settings={tableState}
|
||||
hideShowSystemResources={!isAdmin}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
description={
|
||||
<SystemResourceDescription
|
||||
showSystemResources={tableState.showSystemResources || !isAdmin}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// useConfigMapRowData appends the `inUse` property to the ConfigMap data (for the unused badge in the name column)
|
||||
// and wraps with useMemo to prevent unnecessary calculations
|
||||
function useConfigMapRowData(
|
||||
configMaps: ConfigMap[],
|
||||
applications: Application[],
|
||||
applicationsLoading: boolean
|
||||
): ConfigMapRowData[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
configMaps.map((configMap) => ({
|
||||
...configMap,
|
||||
inUse:
|
||||
// if the apps are loading, set inUse to true to hide the 'unused' badge
|
||||
applicationsLoading || getIsConfigMapInUse(configMap, applications),
|
||||
})),
|
||||
[configMaps, applicationsLoading, applications]
|
||||
);
|
||||
}
|
||||
|
||||
function TableActions({
|
||||
selectedItems,
|
||||
}: {
|
||||
selectedItems: ConfigMapRowData[];
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const deleteConfigMapMutation = useMutationDeleteConfigMaps(environmentId);
|
||||
|
||||
async function handleRemoveClick(configMaps: ConfigMap[]) {
|
||||
const confirmed = await confirmDelete(
|
||||
`Are you sure you want to remove the selected ${pluralize(
|
||||
configMaps.length,
|
||||
'ConfigMap'
|
||||
)}?`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configMapsToDelete = configMaps.map((configMap) => ({
|
||||
namespace: configMap.metadata?.namespace ?? '',
|
||||
name: configMap.metadata?.name ?? '',
|
||||
}));
|
||||
|
||||
await deleteConfigMapMutation.mutateAsync(configMapsToDelete);
|
||||
}
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sConfigMapsW">
|
||||
<Button
|
||||
className="btn-wrapper"
|
||||
color="dangerlight"
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={async () => {
|
||||
handleRemoveClick(selectedItems);
|
||||
}}
|
||||
icon={Trash2}
|
||||
data-cy="k8sConfig-removeConfigButton"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Link to="kubernetes.configmaps.new" className="ml-1">
|
||||
<Button
|
||||
className="btn-wrapper"
|
||||
color="secondary"
|
||||
icon={Plus}
|
||||
data-cy="k8sConfig-addConfigWithFormButton"
|
||||
>
|
||||
Add with form
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="kubernetes.deploy"
|
||||
params={{
|
||||
referrer: 'kubernetes.configurations',
|
||||
tab: 'configmaps',
|
||||
}}
|
||||
className="ml-1"
|
||||
data-cy="k8sConfig-deployFromManifestButton"
|
||||
>
|
||||
<Button className="btn-wrapper" color="primary" icon={Plus}>
|
||||
Create from manifest
|
||||
</Button>
|
||||
</Link>
|
||||
</Authorized>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { formatDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { ConfigMapRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
|
||||
header: 'Created',
|
||||
id: 'created',
|
||||
cell: ({ row }) => getCreatedAtText(row.original),
|
||||
});
|
||||
|
||||
function getCreatedAtText(row: ConfigMapRowData) {
|
||||
const owner =
|
||||
row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
const date = formatDate(row.metadata?.creationTimestamp);
|
||||
return owner ? `${date} by ${owner}` : date;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { ConfigMapRowData } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<ConfigMapRowData>();
|
|
@ -0,0 +1,5 @@
|
|||
import { name } from './name';
|
||||
import { namespace } from './namespace';
|
||||
import { created } from './created';
|
||||
|
||||
export const columns = [name, namespace, created];
|
|
@ -0,0 +1,80 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { ConfigMapRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const name = columnHelper.accessor(
|
||||
(row) => {
|
||||
const name = row.metadata?.name;
|
||||
const namespace = row.metadata?.namespace;
|
||||
|
||||
const isSystemToken = name?.includes('default-token-');
|
||||
const isInSystemNamespace = namespace
|
||||
? isSystemNamespace(namespace)
|
||||
: false;
|
||||
const isSystemConfigMap = isSystemToken || isInSystemNamespace;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
return `${name} ${isSystemConfigMap ? 'system' : ''} ${
|
||||
!isSystemToken && !hasConfigurationOwner ? 'external' : ''
|
||||
} ${!row.inUse && !isSystemConfigMap ? 'unused' : ''}`;
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
cell: Cell,
|
||||
id: 'name',
|
||||
}
|
||||
);
|
||||
|
||||
function Cell({ row }: CellContext<ConfigMapRowData, string>) {
|
||||
const name = row.original.metadata?.name;
|
||||
const namespace = row.original.metadata?.namespace;
|
||||
|
||||
const isSystemToken = name?.includes('default-token-');
|
||||
const isInSystemNamespace = namespace ? isSystemNamespace(namespace) : false;
|
||||
const isSystemConfigMap = isSystemToken || isInSystemNamespace;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.original.metadata?.labels?.[
|
||||
'io.portainer.kubernetes.configuration.owner'
|
||||
];
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sConfigMapsR" childrenUnauthorized={name}>
|
||||
<div className="flex">
|
||||
<Link
|
||||
to="kubernetes.configmaps.configmap"
|
||||
params={{
|
||||
namespace: row.original.metadata?.namespace,
|
||||
name,
|
||||
}}
|
||||
title={name}
|
||||
className="w-fit max-w-xs truncate xl:max-w-sm 2xl:max-w-md"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
{isSystemConfigMap && (
|
||||
<Badge type="success" className="ml-2">
|
||||
system
|
||||
</Badge>
|
||||
)}
|
||||
{!isSystemToken && !hasConfigurationOwner && (
|
||||
<Badge className="ml-2">external</Badge>
|
||||
)}
|
||||
{!row.original.inUse && !isSystemConfigMap && (
|
||||
<Badge type="warn" className="ml-2">
|
||||
unused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Authorized>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { filterHOC } from '@/react/components/datatables/Filter';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { ConfigMapRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const namespace = columnHelper.accessor(
|
||||
(row) => row.metadata?.namespace,
|
||||
{
|
||||
header: 'Namespace',
|
||||
id: 'namespace',
|
||||
cell: ({ getValue }) => {
|
||||
const namespace = getValue();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{
|
||||
id: namespace,
|
||||
}}
|
||||
title={namespace}
|
||||
>
|
||||
{namespace}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
filter: filterHOC('Filter by namespace'),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
filterFn: (
|
||||
row: Row<ConfigMapRowData>,
|
||||
_columnId: string,
|
||||
filterValue: string[]
|
||||
) =>
|
||||
filterValue.length === 0 ||
|
||||
filterValue.includes(row.original.metadata?.namespace ?? ''),
|
||||
}
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export { ConfigMapsDatatable } from './ConfigMapsDatatable';
|
|
@ -0,0 +1,5 @@
|
|||
import { ConfigMap } from 'kubernetes-types/core/v1';
|
||||
|
||||
export interface ConfigMapRowData extends ConfigMap {
|
||||
inUse: boolean;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { ConfigMap, Pod } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { applicationIsKind } from '@/react/kubernetes/applications/utils';
|
||||
|
||||
// getIsConfigMapInUse returns true if the configmap is referenced by any
|
||||
// application in the cluster
|
||||
export function getIsConfigMapInUse(
|
||||
configMap: ConfigMap,
|
||||
applications: Application[]
|
||||
) {
|
||||
return applications.some((app) => {
|
||||
const appSpec = applicationIsKind<Pod>('Pod', app)
|
||||
? app?.spec
|
||||
: app?.spec?.template?.spec;
|
||||
|
||||
const hasEnvVarReference = appSpec?.containers.some((container) =>
|
||||
container.env?.some(
|
||||
(envVar) =>
|
||||
envVar.valueFrom?.configMapKeyRef?.name === configMap.metadata?.name
|
||||
)
|
||||
);
|
||||
const hasVolumeReference = appSpec?.volumes?.some(
|
||||
(volume) => volume.configMap?.name === configMap.metadata?.name
|
||||
);
|
||||
|
||||
return hasEnvVarReference || hasVolumeReference;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { FileCode, Lock } from 'lucide-react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Tab, WidgetTabs, findSelectedTabIndex } from '@@/Widget/WidgetTabs';
|
||||
|
||||
import { ConfigMapsDatatable } from './ConfigMapsDatatable';
|
||||
import { SecretsDatatable } from './SecretsDatatable';
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: 'ConfigMaps',
|
||||
icon: FileCode,
|
||||
widget: <ConfigMapsDatatable />,
|
||||
selectedTabParam: 'configmaps',
|
||||
},
|
||||
{
|
||||
name: 'Secrets',
|
||||
icon: Lock,
|
||||
widget: <SecretsDatatable />,
|
||||
selectedTabParam: 'secrets',
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigmapsAndSecretsView() {
|
||||
const currentTabIndex = findSelectedTabIndex(
|
||||
useCurrentStateAndParams(),
|
||||
tabs
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="ConfigMap & Secret lists"
|
||||
breadcrumbs="ConfigMaps & Secrets"
|
||||
reload
|
||||
/>
|
||||
<>
|
||||
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
|
||||
<div className="content">{tabs[currentTabIndex].widget}</div>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,192 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Lock, Plus, Trash2 } from 'lucide-react';
|
||||
import { Secret } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import {
|
||||
Authorized,
|
||||
useAuthorizations,
|
||||
useCurrentUser,
|
||||
} from '@/react/hooks/useUser';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
|
||||
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import {
|
||||
useSecretsForCluster,
|
||||
useMutationDeleteSecrets,
|
||||
} from '../../secret.service';
|
||||
import { IndexOptional } from '../../types';
|
||||
|
||||
import { getIsSecretInUse } from './utils';
|
||||
import { SecretRowData } from './types';
|
||||
import { columns } from './columns';
|
||||
|
||||
const storageKey = 'k8sSecretsDatatable';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function SecretsDatatable() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const readOnly = !useAuthorizations(['K8sSecretsW']);
|
||||
const { isAdmin } = useCurrentUser();
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(
|
||||
environmentId,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
const namespaceNames = Object.keys(namespaces || {});
|
||||
const { data: secrets, ...secretsQuery } = useSecretsForCluster(
|
||||
environmentId,
|
||||
namespaceNames,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
const { data: applications, ...applicationsQuery } =
|
||||
useApplicationsForCluster(environmentId, namespaceNames);
|
||||
|
||||
const filteredSecrets = useMemo(
|
||||
() =>
|
||||
secrets?.filter(
|
||||
(secret) =>
|
||||
(isAdmin && tableState.showSystemResources) ||
|
||||
!isSystemNamespace(secret.metadata?.namespace ?? '')
|
||||
) || [],
|
||||
[secrets, tableState, isAdmin]
|
||||
);
|
||||
const secretRowData = useSecretRowData(
|
||||
filteredSecrets,
|
||||
applications ?? [],
|
||||
applicationsQuery.isLoading
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable<IndexOptional<SecretRowData>>
|
||||
dataset={secretRowData}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
isLoading={secretsQuery.isLoading || namespacesQuery.isLoading}
|
||||
emptyContentLabel="No secrets found"
|
||||
title="Secrets"
|
||||
titleIcon={Lock}
|
||||
getRowId={(row) => row.metadata?.uid ?? ''}
|
||||
isRowSelectable={(row) =>
|
||||
!isSystemNamespace(row.original.metadata?.namespace ?? '')
|
||||
}
|
||||
disableSelect={readOnly}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<TableActions selectedItems={selectedRows} />
|
||||
)}
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<DefaultDatatableSettings
|
||||
settings={tableState}
|
||||
hideShowSystemResources={!isAdmin}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
description={
|
||||
<SystemResourceDescription
|
||||
showSystemResources={tableState.showSystemResources || !isAdmin}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// useSecretRowData appends the `inUse` property to the secret data (for the unused badge in the name column)
|
||||
// and wraps with useMemo to prevent unnecessary calculations
|
||||
function useSecretRowData(
|
||||
secrets: Secret[],
|
||||
applications: Application[],
|
||||
applicationsLoading: boolean
|
||||
): SecretRowData[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
secrets.map((secret) => ({
|
||||
...secret,
|
||||
inUse:
|
||||
// if the apps are loading, set inUse to true to hide the 'unused' badge
|
||||
applicationsLoading || getIsSecretInUse(secret, applications),
|
||||
})),
|
||||
[secrets, applicationsLoading, applications]
|
||||
);
|
||||
}
|
||||
|
||||
function TableActions({ selectedItems }: { selectedItems: SecretRowData[] }) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const deleteSecretMutation = useMutationDeleteSecrets(environmentId);
|
||||
|
||||
async function handleRemoveClick(secrets: SecretRowData[]) {
|
||||
const confirmed = await confirmDelete(
|
||||
`Are you sure you want to remove the selected ${pluralize(
|
||||
secrets.length,
|
||||
'secret'
|
||||
)}?`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretsToDelete = secrets.map((secret) => ({
|
||||
namespace: secret.metadata?.namespace ?? '',
|
||||
name: secret.metadata?.name ?? '',
|
||||
}));
|
||||
|
||||
await deleteSecretMutation.mutateAsync(secretsToDelete);
|
||||
}
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sSecretsW">
|
||||
<Button
|
||||
className="btn-wrapper"
|
||||
color="dangerlight"
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={async () => {
|
||||
handleRemoveClick(selectedItems);
|
||||
}}
|
||||
icon={Trash2}
|
||||
data-cy="k8sSecret-removeSecretButton"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Link to="kubernetes.secrets.new" className="ml-1">
|
||||
<Button
|
||||
className="btn-wrapper"
|
||||
color="secondary"
|
||||
icon={Plus}
|
||||
data-cy="k8sSecret-addSecretWithFormButton"
|
||||
>
|
||||
Add with form
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="kubernetes.deploy"
|
||||
params={{
|
||||
referrer: 'kubernetes.configurations',
|
||||
tab: 'secrets',
|
||||
}}
|
||||
className="ml-1"
|
||||
data-cy="k8sSecret-deployFromManifestButton"
|
||||
>
|
||||
<Button className="btn-wrapper" color="primary" icon={Plus}>
|
||||
Create from manifest
|
||||
</Button>
|
||||
</Link>
|
||||
</Authorized>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { formatDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { SecretRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
|
||||
header: 'Created',
|
||||
id: 'created',
|
||||
cell: ({ row }) => getCreatedAtText(row.original),
|
||||
});
|
||||
|
||||
function getCreatedAtText(row: SecretRowData) {
|
||||
const owner =
|
||||
row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
const date = formatDate(row.metadata?.creationTimestamp);
|
||||
return owner ? `${date} by ${owner}` : date;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { SecretRowData } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<SecretRowData>();
|
|
@ -0,0 +1,5 @@
|
|||
import { name } from './name';
|
||||
import { namespace } from './namespace';
|
||||
import { created } from './created';
|
||||
|
||||
export const columns = [name, namespace, created];
|
|
@ -0,0 +1,83 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { SecretRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const name = columnHelper.accessor(
|
||||
(row) => {
|
||||
const name = row.metadata?.name;
|
||||
const namespace = row.metadata?.namespace;
|
||||
|
||||
const isSystemToken = name?.includes('default-token-');
|
||||
const isInSystemNamespace = namespace
|
||||
? isSystemNamespace(namespace)
|
||||
: false;
|
||||
const isRegistrySecret =
|
||||
row.metadata?.annotations?.['portainer.io/registry.id'];
|
||||
const isSystemSecret =
|
||||
isSystemToken || isInSystemNamespace || isRegistrySecret;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
|
||||
return `${name} ${isSystemSecret ? 'system' : ''} ${
|
||||
!isSystemToken && !hasConfigurationOwner ? 'external' : ''
|
||||
} ${!row.inUse && !isSystemSecret ? 'unused' : ''}`;
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
cell: Cell,
|
||||
id: 'name',
|
||||
}
|
||||
);
|
||||
|
||||
function Cell({ row }: CellContext<SecretRowData, string>) {
|
||||
const name = row.original.metadata?.name;
|
||||
const namespace = row.original.metadata?.namespace;
|
||||
|
||||
const isSystemToken = name?.includes('default-token-');
|
||||
const isInSystemNamespace = namespace ? isSystemNamespace(namespace) : false;
|
||||
const isSystemSecret = isSystemToken || isInSystemNamespace;
|
||||
|
||||
const hasConfigurationOwner =
|
||||
!!row.original.metadata?.labels?.[
|
||||
'io.portainer.kubernetes.configuration.owner'
|
||||
];
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sSecretsR" childrenUnauthorized={name}>
|
||||
<div className="flex w-fit">
|
||||
<Link
|
||||
to="kubernetes.secrets.secret"
|
||||
params={{
|
||||
namespace: row.original.metadata?.namespace,
|
||||
name,
|
||||
}}
|
||||
title={name}
|
||||
className="w-fit max-w-xs truncate xl:max-w-sm 2xl:max-w-md"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
{isSystemSecret && (
|
||||
<Badge type="success" className="ml-2">
|
||||
system
|
||||
</Badge>
|
||||
)}
|
||||
{!isSystemToken && !hasConfigurationOwner && (
|
||||
<Badge className="ml-2">external</Badge>
|
||||
)}
|
||||
{!row.original.inUse && !isSystemSecret && (
|
||||
<Badge type="warn" className="ml-2">
|
||||
unused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Authorized>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { filterHOC } from '@/react/components/datatables/Filter';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { SecretRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const namespace = columnHelper.accessor(
|
||||
(row) => row.metadata?.namespace,
|
||||
{
|
||||
header: 'Namespace',
|
||||
id: 'namespace',
|
||||
cell: ({ getValue }) => {
|
||||
const namespace = getValue();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{
|
||||
id: namespace,
|
||||
}}
|
||||
title={namespace}
|
||||
>
|
||||
{namespace}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
filter: filterHOC('Filter by namespace'),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
filterFn: (
|
||||
row: Row<SecretRowData>,
|
||||
_columnId: string,
|
||||
filterValue: string[]
|
||||
) =>
|
||||
filterValue.length === 0 ||
|
||||
filterValue.includes(row.original.metadata?.namespace ?? ''),
|
||||
}
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export { SecretsDatatable } from './SecretsDatatable';
|
|
@ -0,0 +1,5 @@
|
|||
import { Secret } from 'kubernetes-types/core/v1';
|
||||
|
||||
export interface SecretRowData extends Secret {
|
||||
inUse: boolean;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { Secret, Pod } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { applicationIsKind } from '@/react/kubernetes/applications/utils';
|
||||
|
||||
// getIsSecretInUse returns true if the secret is referenced by any
|
||||
// application in the cluster
|
||||
export function getIsSecretInUse(secret: Secret, applications: Application[]) {
|
||||
return applications.some((app) => {
|
||||
const appSpec = applicationIsKind<Pod>('Pod', app)
|
||||
? app?.spec
|
||||
: app?.spec?.template?.spec;
|
||||
|
||||
const hasEnvVarReference = appSpec?.containers.some((container) =>
|
||||
container.env?.some(
|
||||
(envVar) =>
|
||||
envVar.valueFrom?.secretKeyRef?.name === secret.metadata?.name
|
||||
)
|
||||
);
|
||||
const hasVolumeReference = appSpec?.volumes?.some(
|
||||
(volume) => volume.secret?.secretName === secret.metadata?.name
|
||||
);
|
||||
|
||||
return hasEnvVarReference || hasVolumeReference;
|
||||
});
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ConfigmapsAndSecretsView } from './ConfigmapsAndSecretsView';
|
|
@ -0,0 +1,163 @@
|
|||
import { ConfigMapList } from 'kubernetes-types/core/v1';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
|
||||
import { queryClient, withError } from '@/react-tools/react-query';
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
error as notifyError,
|
||||
notifySuccess,
|
||||
} from '@/portainer/services/notifications';
|
||||
import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../axiosError';
|
||||
|
||||
export const configMapQueryKeys = {
|
||||
configMaps: (environmentId: EnvironmentId, namespace?: string) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'configmaps',
|
||||
'namespaces',
|
||||
namespace,
|
||||
],
|
||||
configMapsForCluster: (environmentId: EnvironmentId) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'configmaps',
|
||||
],
|
||||
};
|
||||
|
||||
// returns a usequery hook for the list of configmaps from the kubernetes API
|
||||
export function useConfigMaps(
|
||||
environmentId: EnvironmentId,
|
||||
namespace?: string
|
||||
) {
|
||||
return useQuery(
|
||||
configMapQueryKeys.configMaps(environmentId, namespace),
|
||||
() => (namespace ? getConfigMaps(environmentId, namespace) : []),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError(
|
||||
'Failure',
|
||||
err as Error,
|
||||
`Unable to get ConfigMaps in namespace '${namespace}'`
|
||||
);
|
||||
},
|
||||
enabled: !!namespace,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfigMapsForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces?: string[],
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
return useQuery(
|
||||
configMapQueryKeys.configMapsForCluster(environmentId),
|
||||
() => namespaces && getConfigMapsForCluster(environmentId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve ConfigMaps for cluster'),
|
||||
enabled: !!namespaces?.length,
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useMutationDeleteConfigMaps(environmentId: EnvironmentId) {
|
||||
return useMutation(
|
||||
async (configMaps: { namespace: string; name: string }[]) => {
|
||||
const promises = await Promise.allSettled(
|
||||
configMaps.map(({ namespace, name }) =>
|
||||
deleteConfigMap(environmentId, namespace, name)
|
||||
)
|
||||
);
|
||||
const successfulConfigMaps = promises
|
||||
.filter(isFulfilled)
|
||||
.map((_, index) => configMaps[index].name);
|
||||
const failedConfigMaps = promises
|
||||
.filter(isRejected)
|
||||
.map(({ reason }, index) => ({
|
||||
name: configMaps[index].name,
|
||||
reason,
|
||||
}));
|
||||
return { failedConfigMaps, successfulConfigMaps };
|
||||
},
|
||||
{
|
||||
...withError('Unable to remove ConfigMaps'),
|
||||
onSuccess: ({ failedConfigMaps, successfulConfigMaps }) => {
|
||||
// Promise.allSettled can also resolve with errors, so check for errors here
|
||||
queryClient.invalidateQueries(
|
||||
configMapQueryKeys.configMapsForCluster(environmentId)
|
||||
);
|
||||
// show an error message for each configmap that failed to delete
|
||||
failedConfigMaps.forEach(({ name, reason }) => {
|
||||
notifyError(
|
||||
`Failed to remove ConfigMap '${name}'`,
|
||||
new Error(reason.message) as Error
|
||||
);
|
||||
});
|
||||
// show one summary message for all successful deletes
|
||||
if (successfulConfigMaps.length) {
|
||||
notifySuccess(
|
||||
'ConfigMaps successfully removed',
|
||||
successfulConfigMaps.join(', ')
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getConfigMapsForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces: string[]
|
||||
) {
|
||||
try {
|
||||
const configMaps = await Promise.all(
|
||||
namespaces.map((namespace) => getConfigMaps(environmentId, namespace))
|
||||
);
|
||||
return configMaps.flat();
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve ConfigMaps for cluster'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get all configmaps for a namespace
|
||||
async function getConfigMaps(environmentId: EnvironmentId, namespace: string) {
|
||||
try {
|
||||
const { data } = await axios.get<ConfigMapList>(
|
||||
buildUrl(environmentId, namespace)
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve ConfigMaps'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConfigMap(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
await axios.delete(buildUrl(environmentId, namespace, name));
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(e as Error, 'Unable to remove ConfigMap');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: number, namespace: string, name?: string) {
|
||||
const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps`;
|
||||
return name ? `${url}/${name}` : url;
|
||||
}
|
|
@ -23,7 +23,11 @@ export function useConfigurations(
|
|||
() => (namespace ? getConfigurations(environmentId, namespace) : []),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get configurations');
|
||||
notifyError(
|
||||
'Failure',
|
||||
err as Error,
|
||||
`Unable to get configurations for namespace ${namespace}`
|
||||
);
|
||||
},
|
||||
enabled: !!namespace,
|
||||
}
|
||||
|
@ -35,10 +39,10 @@ export function useConfigurationsForCluster(
|
|||
namespaces?: string[]
|
||||
) {
|
||||
return useQuery(
|
||||
['environments', environemtId, 'kubernetes', 'configmaps'],
|
||||
['environments', environemtId, 'kubernetes', 'configurations'],
|
||||
() => namespaces && getConfigMapsForCluster(environemtId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve applications'),
|
||||
...withError('Unable to retrieve configurations for cluster'),
|
||||
enabled: !!namespaces,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
import { SecretList } from 'kubernetes-types/core/v1';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
|
||||
import { queryClient, withError } from '@/react-tools/react-query';
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
error as notifyError,
|
||||
notifySuccess,
|
||||
} from '@/portainer/services/notifications';
|
||||
import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../axiosError';
|
||||
|
||||
export const secretQueryKeys = {
|
||||
secrets: (environmentId: EnvironmentId, namespace?: string) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'secrets',
|
||||
'namespaces',
|
||||
namespace,
|
||||
],
|
||||
secretsForCluster: (environmentId: EnvironmentId) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'secrets',
|
||||
],
|
||||
};
|
||||
|
||||
// returns a usequery hook for the list of secrets from the kubernetes API
|
||||
export function useSecrets(environmentId: EnvironmentId, namespace?: string) {
|
||||
return useQuery(
|
||||
secretQueryKeys.secrets(environmentId, namespace),
|
||||
() => (namespace ? getSecrets(environmentId, namespace) : []),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError(
|
||||
'Failure',
|
||||
err as Error,
|
||||
`Unable to get secrets in namespace '${namespace}'`
|
||||
);
|
||||
},
|
||||
enabled: !!namespace,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useSecretsForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces?: string[],
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
return useQuery(
|
||||
secretQueryKeys.secretsForCluster(environmentId),
|
||||
() => namespaces && getSecretsForCluster(environmentId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve secrets for cluster'),
|
||||
enabled: !!namespaces?.length,
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useMutationDeleteSecrets(environmentId: EnvironmentId) {
|
||||
return useMutation(
|
||||
async (secrets: { namespace: string; name: string }[]) => {
|
||||
const promises = await Promise.allSettled(
|
||||
secrets.map(({ namespace, name }) =>
|
||||
deleteSecret(environmentId, namespace, name)
|
||||
)
|
||||
);
|
||||
const successfulSecrets = promises
|
||||
.filter(isFulfilled)
|
||||
.map((_, index) => secrets[index].name);
|
||||
const failedSecrets = promises
|
||||
.filter(isRejected)
|
||||
.map(({ reason }, index) => ({
|
||||
name: secrets[index].name,
|
||||
reason,
|
||||
}));
|
||||
return { failedSecrets, successfulSecrets };
|
||||
},
|
||||
{
|
||||
...withError('Unable to remove secrets'),
|
||||
onSuccess: ({ failedSecrets, successfulSecrets }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretQueryKeys.secretsForCluster(environmentId)
|
||||
);
|
||||
// show an error message for each secret that failed to delete
|
||||
failedSecrets.forEach(({ name, reason }) => {
|
||||
notifyError(
|
||||
`Failed to remove secret '${name}'`,
|
||||
new Error(reason.message) as Error
|
||||
);
|
||||
});
|
||||
// show one summary message for all successful deletes
|
||||
if (successfulSecrets.length) {
|
||||
notifySuccess(
|
||||
'Secrets successfully removed',
|
||||
successfulSecrets.join(', ')
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getSecretsForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces: string[]
|
||||
) {
|
||||
try {
|
||||
const secrets = await Promise.all(
|
||||
namespaces.map((namespace) => getSecrets(environmentId, namespace))
|
||||
);
|
||||
return secrets.flat();
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve secrets for cluster'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get all secrets for a namespace
|
||||
async function getSecrets(environmentId: EnvironmentId, namespace: string) {
|
||||
try {
|
||||
const { data } = await axios.get<SecretList>(
|
||||
buildUrl(environmentId, namespace)
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(e as Error, 'Unable to retrieve secrets');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSecret(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
await axios.delete(buildUrl(environmentId, namespace, name));
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(e as Error, 'Unable to remove secret');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: number, namespace: string, name?: string) {
|
||||
const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets`;
|
||||
return name ? `${url}/${name}` : url;
|
||||
}
|
|
@ -8,10 +8,13 @@ export interface Configuration {
|
|||
ConfigurationOwner: string;
|
||||
|
||||
Used: boolean;
|
||||
// Applications: any[];
|
||||
Data: Document;
|
||||
Yaml: string;
|
||||
|
||||
SecretType?: string;
|
||||
IsRegistrySecret?: boolean;
|
||||
}
|
||||
|
||||
// Workaround for the TS error `Type 'ConfigMap' does not satisfy the constraint 'Record<string, unknown>'` for the datatable
|
||||
// https://github.com/microsoft/TypeScript/issues/15300#issuecomment-1320480061
|
||||
export type IndexOptional<T> = Pick<T, keyof T>;
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Box, Database, Layers, Lock } from 'lucide-react';
|
||||
import { Box, Database, FileCode, Layers, Lock, Shuffle } from 'lucide-react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
|
||||
import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
|
||||
import { DashboardItem } from '@@/DashboardItem/DashboardItem';
|
||||
|
@ -9,8 +10,11 @@ import { PageHeader } from '@@/PageHeader';
|
|||
|
||||
import { useNamespaces } from '../namespaces/queries';
|
||||
import { useApplicationsForCluster } from '../applications/application.queries';
|
||||
import { useConfigurationsForCluster } from '../configs/queries';
|
||||
import { usePVCsForCluster } from '../volumes/queries';
|
||||
import { useServicesForCluster } from '../services/service';
|
||||
import { useIngresses } from '../ingresses/queries';
|
||||
import { useConfigMapsForCluster } from '../configs/configmap.service';
|
||||
import { useSecretsForCluster } from '../configs/secret.service';
|
||||
|
||||
import { EnvironmentInfo } from './EnvironmentInfo';
|
||||
|
||||
|
@ -21,12 +25,26 @@ export function DashboardView() {
|
|||
const namespaceNames = namespaces && Object.keys(namespaces);
|
||||
const { data: applications, ...applicationsQuery } =
|
||||
useApplicationsForCluster(environmentId, namespaceNames);
|
||||
const { data: configurations, ...configurationsQuery } =
|
||||
useConfigurationsForCluster(environmentId, namespaceNames);
|
||||
const { data: pvcs, ...pvcsQuery } = usePVCsForCluster(
|
||||
environmentId,
|
||||
namespaceNames
|
||||
);
|
||||
const { data: services, ...servicesQuery } = useServicesForCluster(
|
||||
environmentId,
|
||||
namespaceNames
|
||||
);
|
||||
const { data: ingresses, ...ingressesQuery } = useIngresses(
|
||||
environmentId,
|
||||
namespaceNames
|
||||
);
|
||||
const { data: configMaps, ...configMapsQuery } = useConfigMapsForCluster(
|
||||
environmentId,
|
||||
namespaceNames
|
||||
);
|
||||
const { data: secrets, ...secretsQuery } = useSecretsForCluster(
|
||||
environmentId,
|
||||
namespaceNames
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -62,18 +80,51 @@ export function DashboardView() {
|
|||
dataCy="dashboard-application"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={configurations?.length}
|
||||
isLoading={
|
||||
configurationsQuery.isLoading || namespacesQuery.isLoading
|
||||
}
|
||||
value={services?.length}
|
||||
isLoading={servicesQuery.isLoading || namespacesQuery.isLoading}
|
||||
isRefetching={
|
||||
configurationsQuery.isRefetching || namespacesQuery.isRefetching
|
||||
servicesQuery.isRefetching || namespacesQuery.isRefetching
|
||||
}
|
||||
icon={Shuffle}
|
||||
to="kubernetes.services"
|
||||
type="Service"
|
||||
dataCy="dashboard-service"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={ingresses?.length}
|
||||
isLoading={ingressesQuery.isLoading || namespacesQuery.isLoading}
|
||||
isRefetching={
|
||||
ingressesQuery.isRefetching || namespacesQuery.isRefetching
|
||||
}
|
||||
icon={Route}
|
||||
to="kubernetes.ingresses"
|
||||
type="Ingress"
|
||||
pluralType="Ingresses"
|
||||
dataCy="dashboard-ingress"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={configMaps?.length}
|
||||
isLoading={configMapsQuery.isLoading || namespacesQuery.isLoading}
|
||||
isRefetching={
|
||||
configMapsQuery.isRefetching || namespacesQuery.isRefetching
|
||||
}
|
||||
icon={FileCode}
|
||||
to="kubernetes.configurations"
|
||||
params={{ tab: 'configmaps' }}
|
||||
type="ConfigMap"
|
||||
dataCy="dashboard-configmaps"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={secrets?.length}
|
||||
isLoading={secretsQuery.isLoading || namespacesQuery.isLoading}
|
||||
isRefetching={
|
||||
secretsQuery.isRefetching || namespacesQuery.isRefetching
|
||||
}
|
||||
icon={Lock}
|
||||
to="kubernetes.configurations"
|
||||
type="ConfigMaps & Secrets"
|
||||
pluralType="ConfigMaps & Secrets"
|
||||
dataCy="dashboard-config"
|
||||
params={{ tab: 'secrets' }}
|
||||
type="Secret"
|
||||
dataCy="dashboard-secrets"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={pvcs?.length}
|
|
@ -4,7 +4,7 @@ interface Props {
|
|||
showSystemResources: boolean;
|
||||
}
|
||||
|
||||
export function ServicesDatatableDescription({ showSystemResources }: Props) {
|
||||
export function SystemResourceDescription({ showSystemResources }: Props) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{!showSystemResources && (
|
|
@ -0,0 +1,13 @@
|
|||
import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
|
||||
|
||||
import {
|
||||
systemResourcesSettings,
|
||||
TableSettings,
|
||||
} from './DefaultDatatableSettings';
|
||||
|
||||
export function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
...systemResourcesSettings(set),
|
||||
}));
|
||||
}
|
|
@ -373,18 +373,16 @@ export function IngressForm({
|
|||
|
||||
<div className="col-sm-12 p-0">
|
||||
<TextTip color="blue">
|
||||
Add a secret via{' '}
|
||||
You may also use the{' '}
|
||||
<Link
|
||||
to="kubernetes.configurations"
|
||||
to="kubernetes.secrets.new"
|
||||
params={{ id: environmentID }}
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
>
|
||||
ConfigMaps & Secrets
|
||||
</Link>
|
||||
{', '}
|
||||
then select 'Reload TLS secrets' above to
|
||||
populate the dropdown with your changes.
|
||||
Create secret
|
||||
</Link>{' '}
|
||||
function, and reload the dropdown.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -31,10 +31,10 @@ const settingsStore = createPersistedStore(storageKey);
|
|||
export function IngressDatatable() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const nsResult = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const ingressesQuery = useIngresses(
|
||||
environmentId,
|
||||
Object.keys(nsResult?.data || {})
|
||||
Object.keys(namespaces || {})
|
||||
);
|
||||
|
||||
const deleteIngressesMutation = useDeleteIngresses();
|
||||
|
@ -47,7 +47,7 @@ export function IngressDatatable() {
|
|||
settingsManager={tableState}
|
||||
dataset={ingressesQuery.data || []}
|
||||
columns={columns}
|
||||
isLoading={ingressesQuery.isLoading}
|
||||
isLoading={ingressesQuery.isLoading || namespacesQuery.isLoading}
|
||||
emptyContentLabel="No supported ingresses found"
|
||||
title="Ingresses"
|
||||
titleIcon={Route}
|
||||
|
|
|
@ -31,24 +31,29 @@ function Cell({ row }: CellContext<Ingress, string>) {
|
|||
return <div />;
|
||||
}
|
||||
|
||||
return paths.map((path) => {
|
||||
const isHttp = isHTTP(row.original.TLS || [], path.Host);
|
||||
return (
|
||||
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
|
||||
<span className="flex flex-nowrap items-center gap-1 px-2">
|
||||
{link(path.Host, path.Path, isHttp)}
|
||||
<Icon icon={ArrowRight} />
|
||||
{`${path.ServiceName}:${path.Port}`}
|
||||
{!path.HasService && (
|
||||
<Badge type="warn" className="ml-1 gap-1">
|
||||
<Icon icon={AlertTriangle} />
|
||||
Service doesn't exist
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
{paths.map((path) => (
|
||||
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
|
||||
<span className="flex flex-nowrap items-center gap-1 px-2">
|
||||
{link(
|
||||
path.Host,
|
||||
path.Path,
|
||||
isHTTP(row.original.TLS || [], path.Host)
|
||||
)}
|
||||
<Icon icon={ArrowRight} />
|
||||
{`${path.ServiceName}:${path.Port}`}
|
||||
{!path.HasService && (
|
||||
<Badge type="warn" className="ml-1 gap-1">
|
||||
<Icon icon={AlertTriangle} />
|
||||
Service doesn't exist
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isHTTP(TLSs: TLS[], host: string) {
|
||||
|
|
|
@ -6,7 +6,7 @@ export function IngressesDatatableView() {
|
|||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Ingresses"
|
||||
title="Ingress list"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: 'Ingresses',
|
||||
|
|
|
@ -55,7 +55,7 @@ export function useIngress(
|
|||
|
||||
export function useIngresses(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces: string[]
|
||||
namespaces?: string[]
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
|
@ -67,6 +67,9 @@ export function useIngresses(
|
|||
'ingress',
|
||||
],
|
||||
async () => {
|
||||
if (!namespaces?.length) {
|
||||
return [];
|
||||
}
|
||||
const settledIngressesPromise = await Promise.allSettled(
|
||||
namespaces.map((namespace) => getIngresses(environmentId, namespace))
|
||||
);
|
||||
|
@ -110,7 +113,7 @@ export function useIngresses(
|
|||
return filteredIngresses;
|
||||
},
|
||||
{
|
||||
enabled: namespaces.length > 0,
|
||||
enabled: !!namespaces?.length,
|
||||
...withError('Unable to get ingresses'),
|
||||
}
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useQuery } from 'react-query';
|
|||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import {
|
||||
getNamespaces,
|
||||
|
@ -10,7 +11,10 @@ import {
|
|||
} from './service';
|
||||
import { Namespaces } from './types';
|
||||
|
||||
export function useNamespaces(environmentId: EnvironmentId) {
|
||||
export function useNamespaces(
|
||||
environmentId: EnvironmentId,
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'namespaces'],
|
||||
async () => {
|
||||
|
@ -34,8 +38,9 @@ export function useNamespaces(environmentId: EnvironmentId) {
|
|||
return allowedNamespaces;
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get namespaces.');
|
||||
...withError('Unable to get namespaces.'),
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -9,8 +9,8 @@ import {
|
|||
useAuthorizations,
|
||||
useCurrentUser,
|
||||
} from '@/react/hooks/useUser';
|
||||
import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -18,14 +18,18 @@ import { Button } from '@@/buttons';
|
|||
import { Link } from '@@/Link';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useMutationDeleteServices, useServices } from '../service';
|
||||
import { Service } from '../types';
|
||||
import { DefaultDatatableSettings } from '../../datatables/DefaultDatatableSettings';
|
||||
import { isSystemNamespace } from '../../namespaces/utils';
|
||||
import {
|
||||
useMutationDeleteServices,
|
||||
useServicesForCluster,
|
||||
} from '../../service';
|
||||
import { Service } from '../../types';
|
||||
import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings';
|
||||
import { isSystemNamespace } from '../../../namespaces/utils';
|
||||
import { useNamespaces } from '../../../namespaces/queries';
|
||||
import { SystemResourceDescription } from '../../../datatables/SystemResourceDescription';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { createStore } from './datatable-store';
|
||||
import { ServicesDatatableDescription } from './ServicesDatatableDescription';
|
||||
|
||||
const storageKey = 'k8sServicesDatatable';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
@ -33,12 +37,20 @@ const settingsStore = createStore(storageKey);
|
|||
export function ServicesDatatable() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const environmentId = useEnvironmentId();
|
||||
const servicesQuery = useServices(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const namespaceNames = (namespaces && Object.keys(namespaces)) || [];
|
||||
const { data: services, ...servicesQuery } = useServicesForCluster(
|
||||
environmentId,
|
||||
namespaceNames,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
|
||||
const readOnly = !useAuthorizations(['K8sServiceW']);
|
||||
const { isAdmin } = useCurrentUser();
|
||||
|
||||
const filteredServices = servicesQuery.data?.filter(
|
||||
const filteredServices = services?.filter(
|
||||
(service) =>
|
||||
(isAdmin && tableState.showSystemResources) ||
|
||||
!isSystemNamespace(service.Namespace)
|
||||
|
@ -49,14 +61,12 @@ export function ServicesDatatable() {
|
|||
dataset={filteredServices || []}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
isLoading={servicesQuery.isLoading}
|
||||
isLoading={servicesQuery.isLoading || namespacesQuery.isLoading}
|
||||
emptyContentLabel="No services found"
|
||||
title="Services"
|
||||
titleIcon={Shuffle}
|
||||
getRowId={(row) => row.UID}
|
||||
isRowSelectable={(row) =>
|
||||
!KubernetesNamespaceHelper.isSystemNamespace(row.original.Namespace)
|
||||
}
|
||||
isRowSelectable={(row) => !isSystemNamespace(row.original.Namespace)}
|
||||
disableSelect={readOnly}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<TableActions selectedItems={selectedRows} />
|
||||
|
@ -70,7 +80,7 @@ export function ServicesDatatable() {
|
|||
</TableSettingsMenu>
|
||||
)}
|
||||
description={
|
||||
<ServicesDatatableDescription
|
||||
<SystemResourceDescription
|
||||
showSystemResources={tableState.showSystemResources || !isAdmin}
|
||||
/>
|
||||
}
|
||||
|
@ -109,7 +119,10 @@ function TableActions({ selectedItems }: TableActionsProps) {
|
|||
async function handleRemoveClick(services: SelectedService[]) {
|
||||
const confirmed = await confirmDelete(
|
||||
<>
|
||||
<p>Are you sure you want to delete the selected service(s)?</p>
|
||||
<p>{`Are you sure you want to remove the selected ${pluralize(
|
||||
services.length,
|
||||
'service'
|
||||
)}?`}</p>
|
||||
<ul className="pl-6">
|
||||
{services.map((s, index) => (
|
||||
<li key={index}>
|
|
@ -4,7 +4,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
|||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Service } from '../../types';
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { Service } from '../../types';
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { ExternalIPLink } from './ExternalIPLink';
|
||||
import { columnHelper } from './helper';
|
|
@ -1,5 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Service } from '../../types';
|
||||
import { Service } from '../../../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<Service>();
|
|
@ -22,7 +22,7 @@ export const name = columnHelper.accessor(
|
|||
},
|
||||
{
|
||||
header: 'Name',
|
||||
id: 'Name',
|
||||
id: 'name',
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.Name;
|
||||
const isSystem = isSystemNamespace(row.original.Namespace);
|
|
@ -4,7 +4,7 @@ import { filterHOC } from '@/react/components/datatables/Filter';
|
|||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Service } from '../../types';
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
|
@ -2,7 +2,7 @@ import { Row } from '@tanstack/react-table';
|
|||
|
||||
import { filterHOC } from '@@/datatables/Filter';
|
||||
|
||||
import { Service } from '../../types';
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
|
@ -3,7 +3,7 @@ import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
|
|||
import {
|
||||
systemResourcesSettings,
|
||||
TableSettings,
|
||||
} from '../../datatables/DefaultDatatableSettings';
|
||||
} from '../../../datatables/DefaultDatatableSettings';
|
||||
|
||||
export function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
|
|
@ -5,7 +5,7 @@ import { ServicesDatatable } from './ServicesDatatable';
|
|||
export function ServicesView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Service List" breadcrumbs="Services" reload />
|
||||
<PageHeader title="Service list" breadcrumbs="Services" reload />
|
||||
<ServicesDatatable />
|
||||
</>
|
||||
);
|
|
@ -1,5 +0,0 @@
|
|||
## Common Services
|
||||
|
||||
This folder contains rest api services that are shared by different features within kubernetes.
|
||||
|
||||
This includes api requests to the portainer backend, and also requests to the kubernetes api.
|
|
@ -7,8 +7,6 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import { getNamespaces } from '../namespaces/service';
|
||||
|
||||
import { Service } from './types';
|
||||
|
||||
export const queryKeys = {
|
||||
|
@ -16,6 +14,45 @@ export const queryKeys = {
|
|||
['environments', environmentId, 'kubernetes', 'services'] as const,
|
||||
};
|
||||
|
||||
export function useServicesForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceNames?: string[],
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.clusterServices(environmentId),
|
||||
async () => {
|
||||
if (!namespaceNames?.length) {
|
||||
return [];
|
||||
}
|
||||
const settledServicesPromise = await Promise.allSettled(
|
||||
namespaceNames.map((namespace) =>
|
||||
getServices(environmentId, namespace, true)
|
||||
)
|
||||
);
|
||||
return compact(
|
||||
settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value)
|
||||
);
|
||||
},
|
||||
{
|
||||
...withError('Unable to get services.'),
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
enabled: !!namespaceNames?.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useMutationDeleteServices(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(deleteServices, {
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)),
|
||||
...withError('Unable to delete service(s)'),
|
||||
});
|
||||
}
|
||||
|
||||
// get a list of services for a specific namespace from the Portainer API
|
||||
async function getServices(
|
||||
environmentId: EnvironmentId,
|
||||
|
@ -37,24 +74,6 @@ async function getServices(
|
|||
}
|
||||
}
|
||||
|
||||
export function useServices(environmentId: EnvironmentId) {
|
||||
return useQuery(
|
||||
queryKeys.clusterServices(environmentId),
|
||||
async () => {
|
||||
const namespaces = await getNamespaces(environmentId);
|
||||
const settledServicesPromise = await Promise.allSettled(
|
||||
Object.keys(namespaces).map((namespace) =>
|
||||
getServices(environmentId, namespace, true)
|
||||
)
|
||||
);
|
||||
return compact(
|
||||
settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value)
|
||||
);
|
||||
},
|
||||
withError('Unable to get services.')
|
||||
);
|
||||
}
|
||||
|
||||
// getNamespaceServices is used to get a list of services for a specific namespace directly from the Kubernetes API
|
||||
export async function getNamespaceServices(
|
||||
environmentId: EnvironmentId,
|
||||
|
@ -70,15 +89,6 @@ export async function getNamespaceServices(
|
|||
return services.items;
|
||||
}
|
||||
|
||||
export function useMutationDeleteServices(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(deleteServices, {
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)),
|
||||
...withError('Unable to delete service(s)'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteServices({
|
||||
environmentId,
|
||||
data,
|
|
@ -1,11 +0,0 @@
|
|||
export * from './v1IngressClass';
|
||||
export * from './v1ObjectMeta';
|
||||
|
||||
export type KubernetesApiListResponse<T> = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
items: T;
|
||||
metadata: {
|
||||
resourceVersion?: string;
|
||||
};
|
||||
};
|
|
@ -1,48 +0,0 @@
|
|||
import { V1ObjectMeta } from './v1ObjectMeta';
|
||||
|
||||
/**
|
||||
* IngressClassParametersReference identifies an API object. This can be used to specify a cluster or namespace-scoped resource.
|
||||
*/
|
||||
type V1IngressClassParametersReference = {
|
||||
/**
|
||||
* APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required.
|
||||
*/
|
||||
apiGroup?: string;
|
||||
/**
|
||||
* Kind is the type of resource being referenced.
|
||||
*/
|
||||
kind: string;
|
||||
/**
|
||||
* Name is the name of resource being referenced.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Namespace is the namespace of the resource being referenced. This field is required when scope is set to \"Namespace\" and must be unset when scope is set to \"Cluster\".
|
||||
*/
|
||||
namespace?: string;
|
||||
/**
|
||||
* Scope represents if this refers to a cluster or namespace scoped resource. This may be set to \"Cluster\" (default) or \"Namespace\".
|
||||
*/
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
type V1IngressClassSpec = {
|
||||
controller?: string;
|
||||
parameters?: V1IngressClassParametersReference;
|
||||
};
|
||||
|
||||
/**
|
||||
* IngressClass represents the class of the Ingress, referenced by the Ingress Spec. The `ingressclass.kubernetes.io/is-default-class` annotation can be used to indicate that an IngressClass should be considered default. When a single IngressClass resource has this annotation set to true, new Ingress resources without a class specified will be assigned this default class.
|
||||
*/
|
||||
export type V1IngressClass = {
|
||||
/**
|
||||
* APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
*/
|
||||
apiVersion?: string;
|
||||
/**
|
||||
* Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
*/
|
||||
kind?: string;
|
||||
metadata?: V1ObjectMeta;
|
||||
spec?: V1IngressClassSpec;
|
||||
};
|
|
@ -1,33 +0,0 @@
|
|||
// type definitions taken from https://github.com/kubernetes-client/javascript/blob/master/src/gen/model/v1ObjectMeta.ts
|
||||
// and simplified to only include the types we need
|
||||
|
||||
export type V1ObjectMeta = {
|
||||
/**
|
||||
* Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: http://kubernetes.io/docs/user-guide/annotations
|
||||
*/
|
||||
annotations?: { [key: string]: string };
|
||||
/**
|
||||
* Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: http://kubernetes.io/docs/user-guide/labels
|
||||
*/
|
||||
labels?: { [key: string]: string };
|
||||
/**
|
||||
* Deprecated: ClusterName is a legacy field that was always cleared by the system and never used; it will be removed completely in 1.25. The name in the go struct is changed to help clients detect accidental use.
|
||||
*/
|
||||
clusterName?: string;
|
||||
/**
|
||||
* Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces
|
||||
*/
|
||||
namespace?: string;
|
||||
/**
|
||||
* An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
|
||||
*/
|
||||
resourceVersion?: string;
|
||||
/**
|
||||
* UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. Populated by the system. Read-only. More info: http://kubernetes.io/docs/user-guide/identifiers#uids
|
||||
*/
|
||||
uid?: string;
|
||||
};
|
|
@ -5,7 +5,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||
|
||||
import { getPVCsForCluster } from './service';
|
||||
|
||||
// useQuery to get a list of all applications from an array of namespaces
|
||||
// useQuery to get a list of all persistent volume claims from an array of namespaces
|
||||
export function usePVCsForCluster(
|
||||
environemtId: EnvironmentId,
|
||||
namespaces?: string[]
|
||||
|
@ -14,7 +14,7 @@ export function usePVCsForCluster(
|
|||
['environments', environemtId, 'kubernetes', 'pvcs'],
|
||||
() => namespaces && getPVCsForCluster(environemtId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve applications'),
|
||||
...withError('Unable to retrieve perrsistent volume claims'),
|
||||
enabled: !!namespaces,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -28,6 +28,9 @@ export async function getPVCs(environmentId: EnvironmentId, namespace: string) {
|
|||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve deployments');
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve persistent volume claims'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue