feat(config): separate configmaps and secrets [EE-5078] (#9029)

pull/9064/head
Ali 2023-06-12 09:46:48 +12:00 committed by GitHub
parent 4a331b71e1
commit d7fc2046d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
102 changed files with 2845 additions and 665 deletions

View File

@ -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 {

View File

@ -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);

View File

@ -1,13 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesConfigurationsDatatable', {
templateUrl: './configurationsDatatable.html',
controller: 'KubernetesConfigurationsDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
refreshCallback: '<',
removeAction: '<',
},
});

View File

@ -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;

View File

@ -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))), [])

View File

@ -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 -->

View File

@ -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);

View File

@ -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>

View File

@ -0,0 +1,5 @@
angular.module('portainer.kubernetes').component('kubernetesCreateConfigMapView', {
templateUrl: './createConfigMap.html',
controller: 'KubernetesCreateConfigMapController',
controllerAs: 'ctrl',
});

View File

@ -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);

View File

@ -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>

View File

@ -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$: '<',

View File

@ -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);

View File

@ -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>

View File

@ -1,5 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesConfigurationsView', {
templateUrl: './configurations.html',
controller: 'KubernetesConfigurationsController',
controllerAs: 'ctrl',
});

View File

@ -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>

View File

@ -1,5 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesCreateConfigurationView', {
templateUrl: './createConfiguration.html',
controller: 'KubernetesCreateConfigurationController',
controllerAs: 'ctrl',
});

View File

@ -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>

View File

@ -0,0 +1,5 @@
angular.module('portainer.kubernetes').component('kubernetesCreateSecretView', {
templateUrl: './createSecret.html',
controller: 'KubernetesCreateSecretController',
controllerAs: 'ctrl',
});

View File

@ -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);

View File

@ -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>

View File

@ -0,0 +1,8 @@
angular.module('portainer.kubernetes').component('kubernetesSecretView', {
templateUrl: './secret.html',
controller: 'KubernetesSecretController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
},
});

View File

@ -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);

View File

@ -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;

View File

@ -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>>
) {

View File

@ -127,6 +127,7 @@ export const ngModule = angular
'type',
'value',
'to',
'params',
'children',
'pluralType',
'isLoading',

View File

@ -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);

View File

View File

@ -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':

View File

@ -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>
);

View File

@ -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;
}

View File

@ -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();

View File

@ -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>

View File

@ -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,
}
);
}

View File

@ -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}'`
);
}
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { ConfigMapRowData } from '../types';
export const columnHelper = createColumnHelper<ConfigMapRowData>();

View File

@ -0,0 +1,5 @@
import { name } from './name';
import { namespace } from './namespace';
import { created } from './created';
export const columns = [name, namespace, created];

View File

@ -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>
);
}

View File

@ -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 ?? ''),
}
);

View File

@ -0,0 +1 @@
export { ConfigMapsDatatable } from './ConfigMapsDatatable';

View File

@ -0,0 +1,5 @@
import { ConfigMap } from 'kubernetes-types/core/v1';
export interface ConfigMapRowData extends ConfigMap {
inUse: boolean;
}

View File

@ -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;
});
}

View File

@ -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>
</>
</>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { SecretRowData } from '../types';
export const columnHelper = createColumnHelper<SecretRowData>();

View File

@ -0,0 +1,5 @@
import { name } from './name';
import { namespace } from './namespace';
import { created } from './created';
export const columns = [name, namespace, created];

View File

@ -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>
);
}

View File

@ -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 ?? ''),
}
);

View File

@ -0,0 +1 @@
export { SecretsDatatable } from './SecretsDatatable';

View File

@ -0,0 +1,5 @@
import { Secret } from 'kubernetes-types/core/v1';
export interface SecretRowData extends Secret {
inUse: boolean;
}

View File

@ -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;
});
}

View File

@ -0,0 +1 @@
export { ConfigmapsAndSecretsView } from './ConfigmapsAndSecretsView';

View File

@ -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;
}

View File

@ -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,
}
);

View File

@ -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;
}

View File

@ -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>;

View File

@ -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}

View File

@ -4,7 +4,7 @@ interface Props {
showSystemResources: boolean;
}
export function ServicesDatatableDescription({ showSystemResources }: Props) {
export function SystemResourceDescription({ showSystemResources }: Props) {
return (
<div className="w-full">
{!showSystemResources && (

View File

@ -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),
}));
}

View File

@ -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 &amp; Secrets
</Link>
{', '}
then select &apos;Reload TLS secrets&apos; above to
populate the dropdown with your changes.
Create secret
</Link>{' '}
function, and reload the dropdown.
</TextTip>
</div>
</div>

View File

@ -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}

View File

@ -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&apos;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&apos;t exist
</Badge>
)}
</span>
</div>
))}
</div>
);
}
function isHTTP(TLSs: TLS[], host: string) {

View File

@ -6,7 +6,7 @@ export function IngressesDatatableView() {
return (
<>
<PageHeader
title="Ingresses"
title="Ingress list"
breadcrumbs={[
{
label: 'Ingresses',

View File

@ -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'),
}
);

View File

@ -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;
},
}
);

View File

@ -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}>

View File

@ -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';

View File

@ -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';

View File

@ -1,5 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Service } from '../../types';
import { Service } from '../../../types';
export const columnHelper = createColumnHelper<Service>();

View File

@ -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);

View File

@ -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';

View File

@ -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';

View File

@ -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) => ({

View File

@ -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 />
</>
);

View File

@ -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.

View File

@ -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,

View File

@ -1,11 +0,0 @@
export * from './v1IngressClass';
export * from './v1ObjectMeta';
export type KubernetesApiListResponse<T> = {
apiVersion: string;
kind: string;
items: T;
metadata: {
resourceVersion?: string;
};
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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,
}
);

View File

@ -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