feat(k8sconfigure): migrate configure to react [EE-5524] (#10218)

pull/10254/head
Ali 2023-09-05 18:06:36 +02:00 committed by GitHub
parent 0f1e77a6d5
commit 515b02813b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1819 additions and 833 deletions

View File

@ -1,7 +1,5 @@
import _ from 'lodash';
import { usePublicSettings } from '@/react/portainer/settings/queries';
const categories = [
'docker',
'kubernetes',
@ -18,7 +16,7 @@ enum DimensionConfig {
PortainerEndpointUserRole,
}
interface TrackEventProps {
export interface TrackEventProps {
category: Category;
metadata?: Record<string, unknown>;
value?: string | number;
@ -63,20 +61,6 @@ export function push(
}
}
export function useAnalytics() {
const telemetryQuery = usePublicSettings({
select: (settings) => settings.EnableTelemetry,
});
return { trackEvent: handleTrackEvent };
function handleTrackEvent(...args: Parameters<typeof trackEvent>) {
if (telemetryQuery.data) {
trackEvent(...args);
}
}
}
export function trackEvent(action: string, properties: TrackEventProps) {
/**
* @description Logs an event with an event category (Videos, Music, Games...), an event

View File

@ -420,9 +420,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
url: '/configure',
views: {
'content@': {
templateUrl: './views/configure/configure.html',
controller: 'KubernetesConfigureController',
controllerAs: 'ctrl',
component: 'kubernetesConfigureView',
},
},
};

View File

@ -3,7 +3,7 @@ import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector';
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector';
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
@ -30,6 +30,7 @@ export const ngModule = angular
'onChangeControllers',
'description',
'ingressControllers',
'initialIngressControllers',
'allowNoneIngressClass',
'isLoading',
'noIngressControllerLabel',

View File

@ -11,6 +11,7 @@ import { ServicesView } from '@/react/kubernetes/services/ServicesView';
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@ -43,6 +44,10 @@ export const viewsModule = angular
[]
)
)
.component(
'kubernetesConfigureView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
)
.component(
'kubernetesDashboardView',
r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])

View File

@ -1,347 +0,0 @@
<page-header
ng-if="ctrl.state.viewReady"
title="'Kubernetes features configuration'"
breadcrumbs="[
{ label:'Environments', link:'portainer.endpoints' },
{
label:ctrl.endpoint.Name,
link: 'portainer.endpoints.endpoint',
linkParams:{id: ctrl.endpoint.Id}
},
'Kubernetes configuration'
]"
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>
<form class="form-horizontal" name="kubernetesClusterSetupForm">
<div class="col-sm-12 form-section-title"> Networking - Services </div>
<div class="form-group">
<div class="col-sm-12 text-muted small">
<p> Enabling the load balancer feature will allow users to expose application they deploy over an external IP address assigned by cloud provider. </p>
<div class="!inline-flex gap-1 !align-top">
<div class="icon icon-sm"><pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon></div>
<div>Ensure that your cloud provider allows you to create load balancers if you want to use this feature. Might incur costs.</div>
</div>
</div>
<div class="col-sm-12 mt-4">
<label class="control-label col-sm-5 col-lg-4 px-0 text-left"> Allow users to use external load balancer </label>
<label class="switch col-sm-8 mb-0">
<input type="checkbox" ng-model="ctrl.formValues.UseLoadBalancer" /><span class="slider round" data-cy="kubeSetup-loadBalancerToggle"></span>
</label>
</div>
</div>
<div class="col-sm-12 form-section-title"> Networking - Ingresses </div>
<ingress-class-datatable
on-change-controllers="(ctrl.onChangeControllers)"
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
ingress-controllers="ctrl.originalIngressControllers"
is-loading="ctrl.isIngressControllersLoading"
description="'Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here.'"
no-ingress-controller-label="'No supported ingress controllers found.'"
view="'cluster'"
></ingress-class-datatable>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.RestrictStandardUserIngressW"
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
feature-id="ctrl.limitedFeatureIngressDeploy"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
on-change="(ctrl.onToggleRestrictStandardUserIngressW)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
disabled="!ctrl.isRBACEnabled"
>
</por-switch-field>
</div>
</div>
<div class="mb-4 !inline-flex gap-1 !align-top">
<div class="icon icon-sm"><pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon></div>
<div class="text-muted small"
>You may set up ingress defaults (hostnames and annotations) via Create/Edit ingress. Users may then select them via the hostname dropdown in Create/Edit
application.</div
>
</div>
<!-- auto update window -->
<div class="col-sm-12 form-section-title"> Change Window Settings </div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.state.autoUpdateSettings.Enabled"
name="'disableSysctlSettingForRegularUsers'"
label="'Enable Change Window'"
feature-id="ctrl.limitedFeatureAutoWindow"
tooltip="'GitOps updates to stacks or applications outside the defined change window will not occur.'"
on-change="(ctrl.onToggleAutoUpdate)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
>
</por-switch-field>
</div>
</div>
<time-window-picker ng-show="ctrl.state.autoUpdateSettings.Enabled" time-window="ctrl.state.autoUpdateSettings" time-zone="ctrl.state.timeZone"></time-window-picker>
<!-- #region SECURITY -->
<div class="col-sm-12 form-section-title"> Security </div>
<div
ng-if="!ctrl.isRBACEnabled"
class="small mb-6 mt-1 flex w-full gap-1 rounded-lg border border-solid border-warning-5 bg-warning-2 p-4 text-warning-8 th-highcontrast:bg-yellow-11 th-highcontrast:text-white th-dark:bg-yellow-11 th-dark:text-white"
>
<div class="mt-0.5">
<pr-icon icon="'alert-triangle'" feather="true" class-name="'text-warning-7 th-dark:text-white th-highcontrast:text-white'"></pr-icon>
</div>
<div>
<p> Your cluster does not have Kubernetes role-based access control (RBAC) enabled. </p>
<p> This means you can't use Portainer RBAC functionality to regulate access to environment resources based on user roles. </p>
<p class="mb-0">
To enable RBAC, start the&nbsp;<a
class="th-highcontrast:text-blue-4 th-dark:text-blue-7"
href="https://kubernetes.io/docs/concepts/overview/components/#kube-apiserver"
target="_blank"
>API server</a
>&nbsp;with the&nbsp;<code class="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">--authorization-mode</code>&nbsp;flag set to a
comma-separated list that includes&nbsp;<code class="bg-gray-4 th-highcontrast:bg-black th-dark:bg-black">RBAC</code>, for example:&nbsp;
<code class="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">kube-apiserver --authorization-mode=Example1,RBAC,Example2</code>.
</p>
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
By default, all the users have access to the default namespace. Enable this option to set accesses on the default namespace.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.RestrictDefaultNamespace"
name="'restrictDefaultNs'"
label="'Restrict access to the default namespace'"
on-change="(ctrl.onToggleRestrictNs)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictDefaultNsToggle"
disabled="!ctrl.isRBACEnabled"
>
</por-switch-field>
</div>
</div>
<!-- #endregion -->
<div class="col-sm-12 form-section-title"> Resources and Metrics </div>
<div class="form-group">
<div class="col-sm-12 text-muted small">
<p>
By ENABLING resource over-commit, you are able to assign more resources to namespaces than is physically available in the cluster. This may lead to unexpected
deployment failures if there is insufficient resource to service demand.
</p>
<div class="mt-1 inline-flex gap-1 !align-top">
<div class="icon icon-sm"><pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon></div>
<div
>By DISABLING resource over-commit (highly recommended), you are only able to assign resources to namespaces that are less (in aggregate) than the cluster total
minus any system resource reservation.</div
>
</div>
</div>
<div class="col-sm-12 mt-2">
<por-switch-field
data-cy="'kubeSetup-resourceOverCommitToggle'"
label="'Allow resource over-commit'"
name="'resource-over-commit-switch'"
feature-id="ctrl.limitedFeature"
checked="ctrl.formValues.EnableResourceOverCommit"
on-change="(ctrl.onChangeEnableResourceOverCommit)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 text-muted small">
<p> Enabling this feature will allow users to use specific features like autoscaling and to see container and node resource usage. </p>
<div class="mt-1 !inline-flex gap-1 !align-top">
<div class="icon icon-small"><pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon></div>
<div
>Ensure that
<a href="https://kubernetes.io/docs/tasks/debug-application-cluster/resource-metrics-pipeline/#metrics-server" target="_blank">metrics server</a> or
<a href="https://github.com/kubernetes-sigs/prometheus-adapter" target="_blank">prometheus</a> is running inside your cluster.</div
>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label col-sm-5 col-lg-4 px-0 text-left"> Enable features using the metrics API </label>
<label class="switch col-sm-8">
<input type="checkbox" ng-model="ctrl.formValues.UseServerMetrics" ng-change="ctrl.enableMetricsServer()" />
<span class="slider round" data-cy="kubeSetup-metricsToggle"></span>
</label>
</div>
<div ng-if="ctrl.state.metrics.pending && ctrl.state.metrics.userClick" class="col-sm-12 small text-muted" style="margin-top: 5px">
Checking metrics API... <pr-icon icon="'loader'" class-name="'ml-0.5'"></pr-icon>
</div>
<div
ng-if="!ctrl.state.metrics.pending && ctrl.state.metrics.isServerRunning && ctrl.state.metrics.userClick"
class="col-sm-12 small text-muted vertical-center"
style="margin-top: 5px"
>
<pr-icon icon="'check'" mode="'success'"></pr-icon> Successfully reached metrics API
</div>
<div
ng-if="!ctrl.state.metrics.pending && !ctrl.state.metrics.isServerRunning && ctrl.state.metrics.userClick"
class="col-sm-12 small text-muted vertical-center mt-2"
>
<pr-icon icon="'x'" mode="'danger'"></pr-icon> Unable to reach metrics API, make sure metrics server is properly deployed inside that cluster.
</div>
</div>
<div class="col-sm-12 form-section-title"> Available storage options </div>
<div class="form-group" ng-if="!ctrl.storageClassAvailable()">
<div class="col-sm-12 small text-muted vertical-center">
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
Unable to detect any storage class available to persist data. Users won't be able to persist application data inside this cluster.
</div>
</div>
<div class="form-group" ng-if="ctrl.storageClassAvailable()">
<span class="col-sm-12 text-muted small">
<p>
Select which storage options will be available for use when deploying applications. Have a look at your storage driver documentation to figure out which access
policy to configure and if the volume expansion capability is supported.
</p>
<p>
You can find more information about access modes
<a href="https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes" target="_blank">in the official Kubernetes documentation</a>.
</p>
</span>
</div>
<div class="form-group" ng-if="ctrl.storageClassAvailable()">
<div style="margin-top: 10px" class="col-sm-12">
<table class="table" style="table-layout: fixed">
<tbody>
<tr class="text-muted">
<td>Storage</td>
<td>Shared access policy</td>
<td>Volume expansion</td>
</tr>
<tr ng-repeat="class in ctrl.StorageClasses">
<td>
<div class="flex h-full flex-row items-center">
<label class="switch mb-0 mr-2">
<input type="checkbox" ng-model="class.selected" /><span class="slider round" data-cy="kubeSetup-storageToggle{{ class.Name }}"></span>
</label>
<span>{{ class.Name }}</span>
</div>
</td>
<td>
<storage-access-mode-selector
options="ctrl.availableAccessModes"
value="class.AccessModes"
on-change="(ctrl.onChangeStorageClassAccessMode)"
storage-class-name="class.Name"
></storage-access-mode-selector>
</td>
<td>
<div class="flex h-full flex-row items-center">
<label class="switch mb-0 mr-2"
><input type="checkbox" ng-model="class.AllowVolumeExpansion" /><span
class="slider round"
data-cy="kubeSetup-storageExpansionToggle{{ class.Name }}"
></span>
</label>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-12">
<span ng-if="!ctrl.hasValidStorageConfiguration()" class="text-muted small vertical-center mt-2">
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
Shared access policy configuration required
</span>
</div>
</div>
<div class="col-sm-12 form-section-title"> Actions </div>
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button
type="submit"
class="btn btn-primary btn-sm !ml-0"
ng-click="ctrl.configure()"
ng-disabled="ctrl.state.actionInProgress || !kubernetesClusterSetupForm.$valid || !ctrl.hasValidStorageConfiguration()"
button-spinner="ctrl.state.actionInProgress"
analytics-on
analytics-if="ctrl.restrictDefaultToggledOn()"
analytics-category="kubernetes"
analytics-event="kubernetes-configure"
analytics-properties="{ metadata: { restrictAccessToDefaultNamespace: ctrl.formValues.RestrictDefaultNamespace } }"
data-cy="kubeSetup-saveConfigurationButton"
>
<span ng-hide="ctrl.state.actionInProgress">Save configuration</span>
<span ng-show="ctrl.state.actionInProgress">Saving configuration...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@ -1,397 +0,0 @@
import _ from 'lodash-es';
import angular from 'angular';
import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { buildConfirmButton } from '@@/modals/utils';
import { confirm } from '@@/modals/confirm';
import { getIsRBACEnabled } from '@/react/kubernetes/cluster/getIsRBACEnabled';
import { ModalType } from '@@/modals/Modal/types';
import { getMetricsForAllNodes } from '@/react/kubernetes/services/service.ts';
class KubernetesConfigureController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor($async, $state, $scope, Notifications, KubernetesStorageService, EndpointService, EndpointProvider, KubernetesResourcePoolService, KubernetesIngressService) {
this.$async = $async;
this.$state = $state;
this.$scope = $scope;
this.Notifications = Notifications;
this.KubernetesStorageService = KubernetesStorageService;
this.EndpointService = EndpointService;
this.EndpointProvider = EndpointProvider;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.KubernetesIngressService = KubernetesIngressService;
this.IngressClassTypes = KubernetesIngressClassTypes;
this.onInit = this.onInit.bind(this);
this.configureAsync = this.configureAsync.bind(this);
this.areControllersChanged = this.areControllersChanged.bind(this);
this.areFormValuesChanged = this.areFormValuesChanged.bind(this);
this.areStorageClassesChanged = this.areStorageClassesChanged.bind(this);
this.onBeforeOnload = this.onBeforeOnload.bind(this);
this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.limitedFeatureIngressDeploy = FeatureId.K8S_ADM_ONLY_USR_INGRESS_DEPLY;
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onChangeControllers = this.onChangeControllers.bind(this);
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this);
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
this.onToggleRestrictNs = this.onToggleRestrictNs.bind(this);
}
/* #endregion */
/* #region STORAGE CLASSES UI MANAGEMENT */
storageClassAvailable() {
return this.StorageClasses && this.StorageClasses.length > 0;
}
hasValidStorageConfiguration() {
let valid = true;
_.forEach(this.StorageClasses, (item) => {
if (item.selected && item.AccessModes.length === 0) {
valid = false;
}
});
return valid;
}
/* #endregion */
/* #region INGRESS CLASSES UI MANAGEMENT */
onChangeControllers(controllerClassMap) {
this.ingressControllers = controllerClassMap;
}
hasTraefikIngress() {
return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK });
}
toggleAdvancedIngSettings($event) {
$event.stopPropagation();
$event.preventDefault();
this.$scope.$evalAsync(() => {
this.state.isIngToggleSectionExpanded = !this.state.isIngToggleSectionExpanded;
});
}
onToggleAllowNoneIngressClass() {
this.$scope.$evalAsync(() => {
this.formValues.AllowNoneIngressClass = !this.formValues.AllowNoneIngressClass;
});
}
onToggleIngressAvailabilityPerNamespace() {
this.$scope.$evalAsync(() => {
this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace;
});
}
/* #endregion */
/* #region RESOURCES AND METRICS */
onChangeEnableResourceOverCommit(enabled) {
this.$scope.$evalAsync(() => {
this.formValues.EnableResourceOverCommit = enabled;
if (enabled) {
this.formValues.ResourceOverCommitPercentage = 20;
}
});
}
/* #endregion */
/* #region CONFIGURE */
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = this.formValues.EnableResourceOverCommit;
endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage = this.formValues.ResourceOverCommitPercentage;
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace;
endpoint.Kubernetes.Configuration.AllowNoneIngressClass = this.formValues.AllowNoneIngressClass;
endpoint.ChangeWindow = this.state.autoUpdateSettings;
}
transformFormValues() {
const storageClasses = _.map(this.StorageClasses, (item) => {
if (item.selected) {
const res = new KubernetesStorageClass();
res.Name = item.Name;
res.AccessModes = _.map(item.AccessModes, 'Name');
res.Provisioner = item.Provisioner;
res.AllowVolumeExpansion = item.AllowVolumeExpansion;
return res;
}
});
_.pull(storageClasses, undefined);
const ingressClasses = _.without(
_.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic)),
undefined
);
_.pull(ingressClasses, undefined);
return [storageClasses, ingressClasses];
}
async removeIngressesAcrossNamespaces() {
const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true });
if (!ingressesToDel.length) {
return;
}
const promises = [];
const oldEndpoint = this.EndpointProvider.currentEndpoint();
this.EndpointProvider.setCurrentEndpoint(this.endpoint);
try {
const allResourcePools = await this.KubernetesResourcePoolService.get();
const resourcePools = _.filter(
allResourcePools,
(resourcePool) =>
!KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) &&
!KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) &&
resourcePool.Namespace.Status === 'Active'
);
ingressesToDel.forEach((ingress) => {
resourcePools.forEach((resourcePool) => {
promises.push(this.KubernetesIngressService.delete(resourcePool.Namespace.Name, ingress.Name));
});
});
} finally {
this.EndpointProvider.setCurrentEndpoint(oldEndpoint);
}
const responses = await Promise.allSettled(promises);
responses.forEach((respons) => {
if (respons.status == 'rejected' && respons.reason.err.status != 404) {
throw respons.reason;
}
});
}
enableMetricsServer() {
return this.$async(async () => {
if (this.formValues.UseServerMetrics) {
this.state.metrics.userClick = true;
this.state.metrics.pending = true;
try {
await getMetricsForAllNodes(this.endpoint.Id);
this.state.metrics.isServerRunning = true;
this.state.metrics.pending = false;
this.state.metrics.userClick = true;
this.formValues.UseServerMetrics = true;
} catch (_) {
this.state.metrics.isServerRunning = false;
this.state.metrics.pending = false;
this.formValues.UseServerMetrics = false;
}
} else {
this.state.metrics.userClick = false;
this.formValues.UseServerMetrics = false;
}
});
}
async configureAsync() {
try {
this.state.actionInProgress = true;
const [storageClasses, ingressClasses] = this.transformFormValues();
await this.removeIngressesAcrossNamespaces();
this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses);
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
// updateIngressControllerClassMap must be done after updateEndpoint, as a hacky workaround. A better solution: saving ingresscontrollers somewhere else, is being discussed
await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers || []);
this.state.isSaving = true;
const storagePromises = _.map(storageClasses, (storageClass) => {
const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name });
if (oldStorageClass) {
return this.KubernetesStorageService.patch(this.state.endpointId, oldStorageClass, storageClass);
}
});
await Promise.all(storagePromises);
this.$state.reload();
this.Notifications.success('Success', 'Configuration successfully applied');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to apply configuration');
} finally {
this.state.actionInProgress = false;
}
}
configure() {
return this.$async(this.configureAsync);
}
/* #endregion */
restrictDefaultToggledOn() {
return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace;
}
onToggleAutoUpdate(value) {
return this.$scope.$evalAsync(() => {
this.state.autoUpdateSettings.Enabled = value;
});
}
onChangeStorageClassAccessMode(storageClassName, accessModes) {
return this.$scope.$evalAsync(() => {
const storageClass = this.StorageClasses.find((item) => item.Name === storageClassName);
if (!storageClass) {
throw new Error('Storage class not found');
}
storageClass.AccessModes = accessModes;
});
}
onToggleRestrictNs() {
this.$scope.$evalAsync(() => {
this.formValues.RestrictDefaultNamespace = !this.formValues.RestrictDefaultNamespace;
});
}
/* #region ON INIT */
async onInit() {
this.state = {
actionInProgress: false,
displayConfigureClassPanel: {},
viewReady: false,
isIngToggleSectionExpanded: false,
endpointId: this.$state.params.endpointId,
duplicates: {
ingressClasses: new KubernetesFormValidationReferences(),
},
metrics: {
pending: false,
isServerRunning: false,
userClick: false,
},
timeZone: '',
isSaving: false,
};
this.formValues = {
UseLoadBalancer: false,
UseServerMetrics: false,
EnableResourceOverCommit: true,
ResourceOverCommitPercentage: 20,
IngressClasses: [],
RestrictDefaultNamespace: false,
enableAutoUpdateTimeWindow: false,
IngressAvailabilityPerNamespace: false,
};
// default to true if error is thrown
this.isRBACEnabled = true;
this.isIngressControllersLoading = true;
try {
this.availableAccessModes = new KubernetesStorageClassAccessPolicies();
[this.StorageClasses, this.endpoint, this.isRBACEnabled] = await Promise.all([
this.KubernetesStorageService.get(this.state.endpointId),
this.EndpointService.endpoint(this.state.endpointId),
getIsRBACEnabled(this.state.endpointId),
]);
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId });
this.originalIngressControllers = structuredClone(this.ingressControllers) || [];
this.state.autoUpdateSettings = this.endpoint.ChangeWindow;
_.forEach(this.StorageClasses, (item) => {
const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name);
if (storage) {
item.selected = true;
item.AccessModes = storage.AccessModes.map((name) => this.availableAccessModes.find((accessMode) => accessMode.Name === name));
} else if (this.availableAccessModes.length) {
// set a default access mode if the storage class is not enabled and there are available access modes
item.AccessModes = [this.availableAccessModes[0]];
}
});
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
this.formValues.EnableResourceOverCommit = this.endpoint.Kubernetes.Configuration.EnableResourceOverCommit;
this.formValues.ResourceOverCommitPercentage = this.endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage;
this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace;
this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => {
ic.IsNew = false;
ic.NeedsDeletion = false;
return ic;
});
this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace;
this.formValues.AllowNoneIngressClass = this.endpoint.Kubernetes.Configuration.AllowNoneIngressClass;
this.oldStorageClasses = angular.copy(this.StorageClasses);
this.oldFormValues = angular.copy(this.formValues);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve environment configuration');
} finally {
this.state.viewReady = true;
this.isIngressControllersLoading = false;
}
window.addEventListener('beforeunload', this.onBeforeOnload);
}
$onInit() {
return this.$async(this.onInit);
}
/* #endregion */
$onDestroy() {
window.removeEventListener('beforeunload', this.onBeforeOnload);
}
areControllersChanged() {
return !_.isEqual(this.ingressControllers, this.originalIngressControllers);
}
areFormValuesChanged() {
return !_.isEqual(this.formValues, this.oldFormValues);
}
areStorageClassesChanged() {
// angular is pesky and modifies this.StorageClasses (adds $$hashkey to each item)
// angular.toJson removes this to make the comparison work
const storageClassesWithoutHashKey = angular.toJson(this.StorageClasses);
const oldStorageClassesWithoutHashKey = angular.toJson(this.oldStorageClasses);
return !_.isEqual(storageClassesWithoutHashKey, oldStorageClassesWithoutHashKey);
}
onBeforeOnload(event) {
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged() || this.areStorageClassesChanged())) {
event.preventDefault();
event.returnValue = '';
}
}
uiCanExit() {
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged() || this.areStorageClassesChanged()) && !this.isIngressControllersLoading) {
return confirm({
title: 'Are you sure?',
message: 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',
modalType: ModalType.Warn,
confirmButton: buildConfirmButton('Yes', 'danger'),
});
}
}
}
export default KubernetesConfigureController;
angular.module('portainer.kubernetes').controller('KubernetesConfigureController', KubernetesConfigureController);

View File

@ -190,6 +190,7 @@
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="$ctrl.ingressControllers"
initial-ingress-controllers="$ctrl.initialIngressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
view="'namespace'"

View File

@ -7,7 +7,7 @@ import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */
@ -201,6 +201,7 @@ class KubernetesCreateResourcePoolController {
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true });
this.initialIngressControllers = structuredClone(this.ingressControllers);
}
_.forEach(nodes, (item) => {

View File

@ -174,6 +174,7 @@
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="ctrl.ingressControllers"
initial-ingress-controllers="$ctrl.initialIngressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
view="'namespace'"

View File

@ -13,7 +13,7 @@ import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
import { confirmUpdate } from '@@/modals/confirm';
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
@ -373,6 +373,7 @@ class KubernetesResourcePoolController {
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
this.initialIngressControllers = structuredClone(this.ingressControllers);
}
this.pool = _.find(pools, { Namespace: { Name: name } });

View File

@ -16,4 +16,5 @@ export const switchField = r2a(SwitchField, [
'featureId',
'switchClass',
'setTooltipHtmlMessage',
'valueExplanation',
]);

View File

@ -43,23 +43,33 @@ const alertSettings: Record<
export function Alert({
color,
title,
className,
children,
}: PropsWithChildren<{ color: AlertType; title?: string }>) {
}: PropsWithChildren<{
color: AlertType;
title?: string;
className?: string;
}>) {
const { container, header, body, icon } = alertSettings[color];
return (
<AlertContainer className={container}>
<AlertContainer className={clsx(container, className)}>
{title ? (
<>
<AlertHeader className={header}>
<Icon icon={icon} />
{title}
</AlertHeader>
<AlertBody className={body}>{children}</AlertBody>
<AlertBody className={body} hasTitle={!!title}>
{children}
</AlertBody>
</>
) : (
<AlertBody className={clsx(body, 'flex items-center gap-2')}>
<Icon icon={icon} /> {children}
<AlertBody
className={clsx(body, 'flex items-start gap-2')}
hasTitle={!!title}
>
<Icon icon={icon} className="!mt-0.5 flex-none" /> {children}
</AlertBody>
)}
</AlertContainer>
@ -96,7 +106,12 @@ function AlertHeader({
function AlertBody({
className,
hasTitle,
children,
}: PropsWithChildren<{ className?: string }>) {
return <div className={clsx('ml-6 text-sm', className)}>{children}</div>;
}: PropsWithChildren<{ className?: string; hasTitle: boolean }>) {
return (
<div className={clsx('text-sm', className, { 'ml-6': hasTitle })}>
{children}
</div>
);
}

View File

@ -7,6 +7,7 @@ interface Props {
loadingText: string;
isLoading: boolean;
isValid: boolean;
'data-cy'?: string;
}
export function FormActions({
@ -15,6 +16,7 @@ export function FormActions({
isLoading,
children,
isValid,
'data-cy': dataCy,
}: PropsWithChildren<Props>) {
return (
<div className="form-group">
@ -24,6 +26,7 @@ export function FormActions({
loadingText={loadingText}
isLoading={isLoading}
disabled={!isValid}
data-cy={dataCy}
>
{submitLabel}
</LoadingButton>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx';
import { ComponentProps } from 'react';
import uuid from 'uuid';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
@ -24,6 +24,7 @@ export interface Props {
dataCy?: string;
disabled?: boolean;
featureId?: FeatureId;
valueExplanation?: ReactNode;
}
export function SwitchField({
@ -40,7 +41,8 @@ export function SwitchField({
featureId,
switchClass,
setTooltipHtmlMessage,
}: Props) {
valueExplanation,
}: PropsWithChildren<Props>) {
const toggleName = name ? `toggle_${name}` : '';
return (
@ -65,6 +67,7 @@ export function SwitchField({
featureId={featureId}
dataCy={dataCy}
/>
{valueExplanation && <span>{valueExplanation}</span>}
</div>
);
}

View File

@ -12,7 +12,7 @@ import {
} from '@/react-tools/react-query';
import { queryKey as nodesCountQueryKey } from '@/react/portainer/system/useNodesCount';
import { LicenseType } from '@/react/portainer/licenses/types';
import { queryKeys } from '@/react/portainer/environments/queries/query-keys';
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
export function useAssociateDeviceMutation() {
const queryClient = useQueryClient();
@ -22,7 +22,10 @@ export function useAssociateDeviceMutation() {
promiseSequence(ids.map((id) => () => associateDevice(id))),
mutationOptions(
withError('Failed to associate devices'),
withInvalidate(queryClient, [queryKeys.base(), nodesCountQueryKey])
withInvalidate(queryClient, [
environmentQueryKeys.base(),
nodesCountQueryKey,
])
)
);
}

View File

@ -0,0 +1,16 @@
import { trackEvent } from '@/angulartics.matomo/analytics-services';
import { usePublicSettings } from '@/react/portainer/settings/queries';
export function useAnalytics() {
const telemetryQuery = usePublicSettings({
select: (settings) => settings.EnableTelemetry,
});
return { trackEvent: handleTrackEvent };
function handleTrackEvent(...args: Parameters<typeof trackEvent>) {
if (telemetryQuery.data) {
trackEvent(...args);
}
}
}

View File

@ -0,0 +1,412 @@
import { Formik, Form, FormikProps, FormikHelpers } from 'formik';
import { useCallback, useEffect, useMemo } from 'react';
import _ from 'lodash';
import { useTransitionHook } from '@uirouter/react';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormActions } from '@@/form-components/FormActions';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap';
import {
IngressControllerClassMap,
IngressControllerClassMapRowData,
} from '../../ingressClass/types';
import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled';
import { useStorageClassesFormValues } from './useStorageClassesFormValues';
import { ConfigureFormValues, StorageClassFormValues } from './types';
import { configureValidationSchema } from './validation';
import { RBACAlert } from './RBACAlert';
import { EnableMetricsInput } from './EnableMetricsInput';
import { StorageClassDatatable } from './StorageClassDatatable';
import { useConfigureClusterMutation } from './useConfigureClusterMutation';
import { handleSubmitConfigureCluster } from './handleSubmitConfigureCluster';
export function ConfigureForm() {
const { trackEvent } = useAnalytics();
const configureClusterMutation = useConfigureClusterMutation();
// get the initial values
const { data: environment } = useCurrentEnvironment();
const { data: storageClassFormValues } =
useStorageClassesFormValues(environment);
const { data: ingressClasses, ...ingressClassesQuery } =
useIngressControllerClassMapQuery({
environmentId: environment?.Id,
});
const initialValues = useInitialValues(
environment,
storageClassFormValues,
ingressClasses
);
if (!initialValues || !environment) {
return null;
}
return (
<Formik<ConfigureFormValues>
initialValues={initialValues}
onSubmit={(
values: ConfigureFormValues,
formikHelpers: FormikHelpers<ConfigureFormValues>
) => {
handleSubmitConfigureCluster(
values,
initialValues,
configureClusterMutation,
formikHelpers,
trackEvent,
environment
);
}}
validationSchema={configureValidationSchema}
validateOnMount
enableReinitialize // enableReinitialize is needed to update the form values when the ingress classes data is fetched
>
{(formikProps) => (
<InnerForm
// eslint-disable-next-line react/jsx-props-no-spreading
{...formikProps}
isIngressClassesLoading={ingressClassesQuery.isLoading}
environmentId={environment.Id}
/>
)}
</Formik>
);
}
function InnerForm({
initialValues,
setFieldValue,
isValid,
isSubmitting,
values,
errors,
isIngressClassesLoading,
environmentId,
}: FormikProps<ConfigureFormValues> & {
isIngressClassesLoading: boolean;
environmentId: EnvironmentId;
}) {
const { data: isRBACEnabled, ...isRBACEnabledQuery } =
useIsRBACEnabledQuery(environmentId);
const onChangeControllers = useCallback(
(controllerClassMap: IngressControllerClassMap[]) =>
setFieldValue('ingressClasses', controllerClassMap),
[setFieldValue]
);
// when navigating away from the page with unsaved changes, show a portainer prompt to confirm
useTransitionHook('onBefore', {}, async () => {
if (!isFormChanged(values, initialValues)) {
return true;
}
const confirmed = await confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message:
'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',
confirmButton: buildConfirmButton('Yes', 'danger'),
});
return confirmed;
});
// when reloading or exiting the page with unsaved changes, show a browser prompt to confirm
useEffect(() => {
// the handler for showing the prompt
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
function handler(event: BeforeUnloadEvent) {
event.preventDefault();
// eslint-disable-next-line no-param-reassign
event.returnValue = '';
}
// if the form is changed, then set the onbeforeunload
if (isFormChanged(values, initialValues)) {
window.addEventListener('beforeunload', handler);
return () => {
window.removeEventListener('beforeunload', handler);
};
}
return () => {};
}, [values, initialValues]);
return (
<Form className="form-horizontal">
<div className="flex flex-col">
<FormSection title="Networking - Services">
<TextTip color="blue" className="mb-2">
Enabling the load balancer feature will allow users to expose
applications they deploy over an external IP address assigned by the
cloud provider.
</TextTip>
<TextTip color="orange" className="mb-4">
If you want to use this feature, ensure your cloud provider allows
you to create load balancers. This may incur costs.
</TextTip>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="useLoadBalancer"
data-cy="kubeSetup-loadBalancerToggle"
label="Allow users to use external load balancers"
labelClass="col-sm-5 col-lg-4"
checked={values.useLoadBalancer}
onChange={(checked) =>
setFieldValue('useLoadBalancer', checked)
}
/>
</div>
</div>
</FormSection>
<FormSection title="Networking - Ingresses">
<IngressClassDatatable
onChangeControllers={onChangeControllers}
description="Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here."
ingressControllers={
values.ingressClasses as IngressControllerClassMapRowData[]
}
initialIngressControllers={
initialValues.ingressClasses as IngressControllerClassMapRowData[]
}
allowNoneIngressClass={values.allowNoneIngressClass}
isLoading={isIngressClassesLoading}
noIngressControllerLabel="No supported ingress controllers found."
view="cluster"
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="allowNoneIngressClass"
data-cy="kubeSetup-allowNoneIngressClass"
label='Allow ingress class to be set to "none"'
tooltip='This allows users setting up ingresses to select "none" as the ingress class.'
labelClass="col-sm-5 col-lg-4"
checked={values.allowNoneIngressClass}
onChange={(checked) =>
setFieldValue('allowNoneIngressClass', checked)
}
/>
</div>
</div>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="ingressAvailabilityPerNamespace"
data-cy="kubeSetup-ingressAvailabilityPerNamespace"
label="Configure ingress controller availability per namespace"
tooltip="This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications."
labelClass="col-sm-5 col-lg-4"
checked={values.ingressAvailabilityPerNamespace}
onChange={(checked) =>
setFieldValue('ingressAvailabilityPerNamespace', checked)
}
/>
</div>
</div>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="restrictStandardUserIngressW"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
label="Only allow admins to deploy ingresses"
featureId={FeatureId.K8S_ADM_ONLY_USR_INGRESS_DEPLY}
tooltip="Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so)."
labelClass="col-sm-5 col-lg-4"
checked={values.restrictStandardUserIngressW}
onChange={(checked) =>
setFieldValue('restrictStandardUserIngressW', checked)
}
/>
</div>
</div>
<TextTip color="blue" className="mb-5">
You may set up ingress defaults (hostnames and annotations) via
Create/Edit ingress. Users may then select them via the hostname
dropdown in Create/Edit application.
</TextTip>
</FormSection>
<FormSection title="Change Window Settings">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="changeWindow.Enabled"
data-cy="kubeSetup-changeWindowEnabledToggle"
label="Enable Change Window"
tooltip="GitOps updates to stacks or applications outside the defined change window will not occur.'"
labelClass="col-sm-5 col-lg-4"
checked={false}
featureId={FeatureId.HIDE_AUTO_UPDATE_WINDOW}
onChange={() => {}}
/>
</div>
</div>
</FormSection>
<FormSection title="Security">
{!isRBACEnabled && isRBACEnabledQuery.isSuccess && <RBACAlert />}
<TextTip color="blue">
<p>
By default, all the users have access to the default namespace.
Enable this option to set accesses on the default namespace.
</p>
</TextTip>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="restrictDefaultNamespace"
data-cy="kubeSetup-restrictDefaultNsToggle"
label="Restrict access to the default namespace"
labelClass="col-sm-5 col-lg-4"
checked={values.restrictDefaultNamespace}
onChange={(checked) =>
setFieldValue('restrictDefaultNamespace', checked)
}
/>
</div>
</div>
</FormSection>
<FormSection title="Resources and Metrics">
<TextTip color="orange">
<p>
By ENABLING resource over-commit, you are able to assign more
resources to namespaces than is physically available in the
cluster. This may lead to unexpected deployment failures if there
is insufficient resource to service demand.
</p>
</TextTip>
<TextTip color="blue">
<p>
By DISABLING resource over-commit (highly recommended), you are
only able to assign resources to namespaces that are less (in
aggregate) than the cluster total minus any system resource
reservation.
</p>
</TextTip>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Allow resource over-commit"
labelClass="col-sm-5 col-lg-4"
name="resourceOverCommitPercentage"
checked={values.enableResourceOverCommit}
featureId={FeatureId.K8S_SETUP_DEFAULT}
onChange={(checked: boolean) => {
setFieldValue('enableResourceOverCommit', checked);
// set 20% as the default resourceOverCommitPercentage value
if (!checked) {
setFieldValue('resourceOverCommitPercentage', 20);
}
}}
data-cy="kubeSetup-resourceOverCommitToggle"
/>
</div>
</div>
<EnableMetricsInput
environmentId={environmentId}
error={errors.useServerMetrics}
value={values.useServerMetrics}
/>
</FormSection>
<FormSection title="Available storage options">
{initialValues.storageClasses.length === 0 && (
<TextTip color="orange" className="mb-4">
Unable to detect any storage class available to persist data.
Users won&apos;t be able to persist application data inside this
cluster.
</TextTip>
)}
{initialValues.storageClasses.length > 0 && (
<>
<TextTip color="blue">
<p>
Select which storage options will be available for use when
deploying applications. Have a look at your storage driver
documentation to figure out which access policy to configure
and if the volume expansion capability is supported.
</p>
<p>
You can find more information about access modes{' '}
<a
href="https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes"
target="_blank"
rel="noreferrer"
>
in the official Kubernetes documentation
</a>
.
</p>
</TextTip>
<StorageClassDatatable
storageClassValues={values.storageClasses}
/>
</>
)}
</FormSection>
<FormActions
submitLabel="Save configuration"
loadingText="Saving configuration"
isLoading={isSubmitting}
isValid={
isValid &&
!isIngressClassesLoading &&
isFormChanged(values, initialValues)
}
data-cy="kubeSetup-saveConfigurationButton"
/>
</div>
</Form>
);
}
function useInitialValues(
environment?: Environment | null,
storageClassFormValues?: StorageClassFormValues[],
ingressClasses?: IngressControllerClassMapRowData[]
): ConfigureFormValues | undefined {
return useMemo(() => {
if (!environment) {
return undefined;
}
return {
storageClasses: storageClassFormValues || [],
useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer,
useServerMetrics: !!environment.Kubernetes.Configuration.UseServerMetrics,
enableResourceOverCommit:
!!environment.Kubernetes.Configuration.EnableResourceOverCommit,
resourceOverCommitPercentage:
environment.Kubernetes.Configuration.ResourceOverCommitPercentage || 20,
restrictDefaultNamespace:
!!environment.Kubernetes.Configuration.RestrictDefaultNamespace,
restrictStandardUserIngressW:
!!environment.Kubernetes.Configuration.RestrictStandardUserIngressW,
ingressAvailabilityPerNamespace:
!!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
allowNoneIngressClass:
!!environment.Kubernetes.Configuration.AllowNoneIngressClass,
ingressClasses: ingressClasses || [],
};
}, [environment, ingressClasses, storageClassFormValues]);
}
function isFormChanged(
values: ConfigureFormValues,
initialValues: ConfigureFormValues
) {
// check if the form values are different from the initial values
return !_.isEqual(values, initialValues);
}

View File

@ -0,0 +1,101 @@
import { Field, useFormikContext } from 'formik';
import { useState } from 'react';
import { CheckCircle, XCircle } from 'lucide-react';
import { useGetMetricsMutation } from '@/react/kubernetes/queries/useGetMetricsMutation';
import { TextTip } from '@@/Tip/TextTip';
import { FormControl } from '@@/form-components/FormControl';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { InlineLoader } from '@@/InlineLoader';
import { ConfigureFormValues } from './types';
type Props = {
environmentId: number;
value: boolean;
error?: string;
};
export function EnableMetricsInput({ value, error, environmentId }: Props) {
const { setFieldValue } = useFormikContext<ConfigureFormValues>();
const [metricsFound, setMetricsFound] = useState<boolean>();
const getMetricsMutation = useGetMetricsMutation();
return (
<div className="mb-4">
<TextTip color="blue">
<p>
Enabling this feature will allow users to use specific features like
autoscaling and to see container and node resource usage.
</p>
<p>
Ensure that&nbsp;
<a
href="https://kubernetes.io/docs/tasks/debug-application-cluster/resource-metrics-pipeline/#metrics-server"
target="_blank"
rel="noreferrer"
>
metrics server
</a>
&nbsp;or&nbsp;
<a
href="https://github.com/kubernetes-sigs/prometheus-adapter"
target="_blank"
rel="noreferrer"
>
prometheus
</a>
&nbsp;is running inside your cluster.
</p>
</TextTip>
<FormControl
label="Enable features using the metrics API"
className="mb-0"
size="large"
errors={error}
>
<Field
name="useServerMetrics"
as={Switch}
checked={value}
onChange={(checked: boolean) => {
// if turning off, just set the value
if (!checked) {
setFieldValue('useServerMetrics', checked);
return;
}
// if turning on, see if the metrics server is available, then set the value to on if it is
getMetricsMutation.mutate(environmentId, {
onSuccess: () => {
setMetricsFound(true);
setFieldValue('useServerMetrics', checked);
},
onError: () => {
setMetricsFound(false);
},
});
}}
data-cy="kubeSetup-metricsToggle"
/>
</FormControl>
{getMetricsMutation.isLoading && (
<InlineLoader size="sm">Checking metrics API...</InlineLoader>
)}
{!getMetricsMutation.isLoading && (
<>
{metricsFound === false && (
<TextTip color="red" icon={XCircle}>
Unable to reach metrics API, make sure metrics server is properly
deployed inside that cluster.
</TextTip>
)}
{metricsFound === true && (
<TextTip color="green" icon={CheckCircle}>
Successfully reached metrics API
</TextTip>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,39 @@
import { Alert } from '@@/Alert';
export function RBACAlert() {
return (
<Alert color="warn" className="mb-4">
<div className="flex-flex-col">
<p>
Your cluster does not have Kubernetes role-based access control (RBAC)
enabled.
</p>
<p>This means you can&apos;t use Portainer RBAC functionality to</p>
<p className="mb-0">
To enable RBAC, start the&nbsp;
<a
className="th-highcontrast:text-blue-4 th-dark:text-blue-7"
href="https://kubernetes.io/docs/concepts/overview/components/#kube-apiserver"
target="_blank"
rel="noreferrer"
>
API server
</a>
&nbsp;with the&nbsp;
<code className="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">
--authorization-mode
</code>
&nbsp;flag set to a comma-separated list that includes&nbsp;
<code className="bg-gray-4 th-highcontrast:bg-black th-dark:bg-black">
RBAC
</code>
, for example:&nbsp;
<code className="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">
kube-apiserver --authorization-mode=Example1,RBAC,Example2
</code>
.
</p>
</div>
</Alert>
);
}

View File

@ -9,7 +9,7 @@ interface Option {
interface Props {
value: Option[];
onChange(storageClassName: string, value: readonly Option[]): void;
onChange(value: readonly Option[]): void;
options: Option[];
inputId?: string;
storageClassName: string;
@ -31,7 +31,7 @@ export function StorageAccessModeSelector({
options={options}
value={value}
closeMenuOnSelect={false}
onChange={(value) => onChange(storageClassName, value)}
onChange={(value) => onChange(value)}
inputId={inputId}
placeholder="Not configured"
data-cy={`kubeSetup-storageAccessSelect${storageClassName}`}

View File

@ -0,0 +1,104 @@
import { useFormikContext } from 'formik';
import { TextTip } from '@@/Tip/TextTip';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { StorageAccessModeSelector } from './StorageAccessModeSelector';
import { ConfigureFormValues, StorageClassFormValues } from './types';
import { availableStorageClassPolicies } from './useStorageClassesFormValues';
type Props = {
storageClassValues: StorageClassFormValues[];
};
export function StorageClassDatatable({ storageClassValues }: Props) {
const { setFieldValue } = useFormikContext<ConfigureFormValues>();
return (
<div className="form-group">
<div className="col-sm-12 mt-2.5">
<table className="table table-fixed">
<tbody>
<tr className="text-muted">
<td>Storage</td>
<td>Shared access policy</td>
<td>Volume expansion</td>
</tr>
{storageClassValues.map((storageClassValue, index) => (
<tr
key={`${storageClassValue.Name}${storageClassValue.Provisioner}`}
>
<td>
<div className="flex h-full flex-row items-center">
<Switch
checked={storageClassValue.selected}
onChange={(checked) =>
setFieldValue(
`storageClasses.${index}.selected`,
checked
)
}
className="mr-2 mb-0"
id={`kubeSetup-storageToggle${storageClassValue.Name}`}
name={`kubeSetup-storageToggle${storageClassValue.Name}`}
dataCy={`kubeSetup-storageToggle${storageClassValue.Name}`}
/>
<span>{storageClassValue.Name}</span>
</div>
</td>
<td>
<StorageAccessModeSelector
options={availableStorageClassPolicies}
value={storageClassValue.AccessModes}
onChange={(accessModes) => {
setFieldValue(
`storageClasses.${index}.AccessModes`,
accessModes
);
}}
storageClassName={storageClassValue.Name}
/>
</td>
<td>
<div className="flex h-full flex-row items-center">
<Switch
checked={storageClassValue.AllowVolumeExpansion}
onChange={(checked) =>
setFieldValue(
`storageClasses.${index}.AllowVolumeExpansion`,
checked
)
}
className="mr-2 mb-0"
dataCy={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
id={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
name={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!hasValidStorageConfiguration(storageClassValues) && (
<div className="col-sm-12">
<TextTip color="orange">
Shared access policy configuration required.
</TextTip>
</div>
)}
</div>
);
}
function hasValidStorageConfiguration(
storageClassValues: StorageClassFormValues[]
) {
return storageClassValues.every(
(storageClassValue) =>
// if the storage class is not selected, it's valid
!storageClassValue.selected ||
// if the storage class is selected, it must have at least one access mode
storageClassValue.AccessModes.length > 0
);
}

View File

@ -0,0 +1,145 @@
import { FormikHelpers } from 'formik';
import { StorageClass } from 'kubernetes-types/storage/v1';
import { compare } from 'fast-json-patch';
import { UseMutationResult } from 'react-query';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
import { Environment } from '@/react/portainer/environments/types';
import { TrackEventProps } from '@/angulartics.matomo/analytics-services';
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
import { ConfigureFormValues, StorageClassFormValues } from './types';
import { ConfigureClusterPayloads } from './useConfigureClusterMutation';
// handle the form submission
export async function handleSubmitConfigureCluster(
values: ConfigureFormValues,
initialValues: ConfigureFormValues | undefined,
configureClusterMutation: UseMutationResult<
void,
unknown,
ConfigureClusterPayloads,
unknown
>,
{ resetForm }: FormikHelpers<ConfigureFormValues>,
trackEvent: (action: string, properties: TrackEventProps) => void,
environment?: Environment
) {
if (!environment) {
notifyError('Unable to save configuration: environment not found');
return;
}
// send metrics if needed
if (
values.restrictDefaultNamespace &&
!initialValues?.restrictDefaultNamespace
) {
trackEvent('kubernetes-configure', {
category: 'kubernetes',
metadata: {
restrictAccessToDefaultNamespace: values.restrictDefaultNamespace,
},
});
}
// transform the form values into the environment object
const selectedStorageClasses = values.storageClasses.filter(
(storageClass) => storageClass.selected
);
const updatedEnvironment = assignFormValuesToEnvironment(
environment,
values,
selectedStorageClasses
);
const storageClassPatches = createStorageClassPatches(
selectedStorageClasses,
initialValues?.storageClasses
);
// update the environment using a react query mutation
await configureClusterMutation.mutateAsync(
{
id: environment.Id,
updateEnvironmentPayload: updatedEnvironment,
ingressControllers:
values.ingressClasses as IngressControllerClassMapRowData[],
storageClassPatches,
},
{
onSuccess: () => {
notifySuccess('Success', 'Configuration successfully applied');
resetForm();
},
}
);
}
function createStorageClassPatches(
storageClasses: StorageClassFormValues[],
oldStorageClasses?: StorageClassFormValues[]
) {
const storageClassPatches = storageClasses.flatMap((storageClass) => {
const oldStorageClass = oldStorageClasses?.find(
(sc) => sc.Name === storageClass.Name
);
if (!oldStorageClass) {
return [];
}
const newPayload = createStorageClassPayload(storageClass);
const oldPayload = createStorageClassPayload(oldStorageClass);
const patch = compare(oldPayload, newPayload);
return [{ name: storageClass.Name, patch }];
});
return storageClassPatches;
}
function createStorageClassPayload(storageClass: StorageClassFormValues) {
const payload: StorageClass = {
provisioner: storageClass.Provisioner,
allowVolumeExpansion: storageClass.AllowVolumeExpansion,
metadata: {
uid: '',
name: storageClass.Name,
namespace: '',
labels: {},
annotations: {},
},
};
return payload;
}
function assignFormValuesToEnvironment(
environment: Environment,
values: ConfigureFormValues,
selectedStorageClasses: StorageClassFormValues[]
) {
// note that the ingress datatable form values are omitted and included in another call
const updatedEnvironment: Partial<UpdateEnvironmentPayload> = {
Kubernetes: {
...environment.Kubernetes,
Configuration: {
...environment.Kubernetes.Configuration,
UseLoadBalancer: values.useLoadBalancer,
UseServerMetrics: values.useServerMetrics,
EnableResourceOverCommit: values.enableResourceOverCommit,
ResourceOverCommitPercentage: values.resourceOverCommitPercentage,
RestrictDefaultNamespace: values.restrictDefaultNamespace,
RestrictStandardUserIngressW: values.restrictStandardUserIngressW,
IngressAvailabilityPerNamespace: values.ingressAvailabilityPerNamespace,
AllowNoneIngressClass: values.allowNoneIngressClass,
StorageClasses: selectedStorageClasses.map((storageClass) => ({
Name: storageClass.Name,
AccessModes: storageClass.AccessModes.map(
(accessMode) => accessMode.Name
),
AllowVolumeExpansion: storageClass.AllowVolumeExpansion,
Provisioner: storageClass.Provisioner,
})),
},
},
};
return updatedEnvironment;
}

View File

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

View File

@ -0,0 +1,28 @@
import { IngressControllerClassMap } from '../../ingressClass/types';
export type AccessMode = {
Description: string;
Name: string;
selected: boolean;
};
export type StorageClassFormValues = {
Name: string;
AccessModes: AccessMode[];
Provisioner: string;
AllowVolumeExpansion: boolean;
selected: boolean;
};
export type ConfigureFormValues = {
useLoadBalancer: boolean;
useServerMetrics: boolean;
enableResourceOverCommit: boolean;
resourceOverCommitPercentage: number;
restrictDefaultNamespace: boolean;
restrictStandardUserIngressW: boolean;
ingressAvailabilityPerNamespace: boolean;
allowNoneIngressClass: boolean;
storageClasses: StorageClassFormValues[];
ingressClasses: IngressControllerClassMap[];
};

View File

@ -0,0 +1,72 @@
import { useMutation, useQueryClient } from 'react-query';
import { Operation } from 'fast-json-patch';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
import {
UpdateEnvironmentPayload,
updateEnvironment,
} from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
import axios from '@/portainer/services/axios';
import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError';
import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap';
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
export type ConfigureClusterPayloads = {
id: number;
updateEnvironmentPayload: Partial<UpdateEnvironmentPayload>;
ingressControllers: IngressControllerClassMapRowData[];
storageClassPatches: {
name: string;
patch: Operation[];
}[];
};
// useConfigureClusterMutation updates the environment, the ingress classes and the storage classes
export function useConfigureClusterMutation() {
const queryClient = useQueryClient();
return useMutation(
async ({
id,
updateEnvironmentPayload,
ingressControllers,
storageClassPatches,
}: ConfigureClusterPayloads) => {
await updateEnvironment({ id, payload: updateEnvironmentPayload });
await Promise.all(
storageClassPatches.map(({ name, patch }) =>
patchStorageClass(id, name, patch)
)
);
await updateIngressControllerClassMap(id, ingressControllers);
},
{
...withInvalidate(queryClient, [environmentQueryKeys.base()]),
...withError('Unable to apply configuration', 'Failure'),
}
);
}
async function patchStorageClass(
environmentId: number,
name: string,
storageClassPatch: Operation[]
) {
try {
await axios.patch(
`/endpoints/${environmentId}/kubernetes/apis/storage.k8s.io/v1/storageclasses/${name}`,
storageClassPatch,
{
headers: {
'Content-Type': 'application/json-patch+json',
},
}
);
} catch (e) {
throw parseKubernetesAxiosError(
e as Error,
`Unable to patch StorageClass ${name}`
);
}
}

View File

@ -0,0 +1,111 @@
import { useQuery } from 'react-query';
import { StorageClass, StorageClassList } from 'kubernetes-types/storage/v1';
import axios from '@/portainer/services/axios';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { parseKubernetesAxiosError } from '../../../axiosError';
import { AccessMode, StorageClassFormValues } from './types';
export const availableStorageClassPolicies = [
{
Name: 'RWO',
Description: 'Allow read-write from a single pod only (RWO)',
selected: true,
},
{
Name: 'RWX',
Description:
'Allow read-write access from one or more pods concurrently (RWX)',
selected: false,
},
];
export function useStorageClassesFormValues(
environment: Environment | null | undefined
) {
return useQuery(
[
'environments',
environment?.Id,
'kubernetes',
'storageclasses',
// include the storage classes in the cache key to force a refresh when the storage classes change in the environment object
JSON.stringify(environment?.Kubernetes.Configuration.StorageClasses),
],
async () => {
if (!environment) {
return [];
}
const storageClasses = await getStorageClasses(environment.Id);
const storageClassFormValues = transformStorageClassesToFormValues(
storageClasses,
environment
);
return storageClassFormValues;
},
{
...withError('Failure', `Unable to get Storage Classes`),
enabled: !!environment,
}
);
}
async function getStorageClasses(
environmentId: EnvironmentId
): Promise<StorageClass[]> {
try {
const { data: storageClassList } = await axios.get<StorageClassList>(
`/endpoints/${environmentId}/kubernetes/apis/storage.k8s.io/v1/storageclasses`
);
return storageClassList.items;
} catch (e) {
throw parseKubernetesAxiosError(
e as Error,
'Unable to retrieve Storage Classes'
);
}
}
function transformStorageClassesToFormValues(
storageClasses: StorageClass[],
environment: Environment
) {
const storageClassFormValues: StorageClassFormValues[] = storageClasses.map(
(storageClass) => {
const enabledStorage =
environment.Kubernetes.Configuration.StorageClasses?.find(
(sc) => sc.Name === storageClass.metadata?.name
);
let selected = false;
let AccessModes: AccessMode[] = [];
if (enabledStorage) {
selected = true;
AccessModes =
enabledStorage.AccessModes.flatMap(
(name) =>
availableStorageClassPolicies.find(
(accessMode) => accessMode.Name === name
) || []
) || [];
} else {
// set a default access mode if the storage class is not enabled and there are available access modes
AccessModes = [availableStorageClassPolicies[0]];
}
return {
Name: storageClass.metadata?.name || '',
Provisioner: storageClass.provisioner,
AllowVolumeExpansion: !!storageClass.allowVolumeExpansion,
selected,
AccessModes,
};
}
);
return storageClassFormValues;
}

View File

@ -0,0 +1,62 @@
import { object, string, boolean, array, number, SchemaOf } from 'yup';
import { IngressControllerClassMap } from '../../ingressClass/types';
import { ConfigureFormValues } from './types';
// Define Yup schema for AccessMode
const accessModeSchema = object().shape({
Description: string().required(),
Name: string().required(),
selected: boolean().required(),
});
// Define Yup schema for StorageClassFormValues
const storageClassFormValuesSchema = array()
.of(
object().shape({
Name: string().required(),
AccessModes: array().of(accessModeSchema).required(),
Provisioner: string().required(),
AllowVolumeExpansion: boolean().required(),
selected: boolean().required(),
})
)
.test(
// invalid if any storage class is not selected or if it's selected and at least one access mode is selected
'accessModes',
'Shared access policy configuration required.',
(storageClasses) => {
const isValid = storageClasses?.every(
(value) =>
!value.selected ||
value.AccessModes?.some((accessMode) => accessMode.selected)
);
return isValid || false;
}
);
// Define Yup schema for IngressControllerClassMap
const ingressControllerClassMapSchema: SchemaOf<IngressControllerClassMap> =
object().shape({
Name: string().required(),
ClassName: string().required(),
Type: string().required(),
Availability: boolean().required(),
New: boolean().required(),
Used: boolean().required(),
});
// Define Yup schema for ConfigureFormValues
export const configureValidationSchema: SchemaOf<ConfigureFormValues> = object({
useLoadBalancer: boolean().required(),
useServerMetrics: boolean().required(),
enableResourceOverCommit: boolean().required(),
resourceOverCommitPercentage: number().required(),
restrictDefaultNamespace: boolean().required(),
restrictStandardUserIngressW: boolean().required(),
ingressAvailabilityPerNamespace: boolean().required(),
allowNoneIngressClass: boolean().required(),
storageClasses: storageClassFormValuesSchema.required(),
ingressClasses: array().of(ingressControllerClassMapSchema).required(),
});

View File

@ -0,0 +1,39 @@
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { PageHeader } from '@@/PageHeader';
import { Widget, WidgetBody } from '@@/Widget';
import { ConfigureForm } from './ConfigureForm';
export function ConfigureView() {
const { data: environment } = useCurrentEnvironment();
// get the initial values
return (
<>
<PageHeader
title="Kubernetes features configuration"
reload
breadcrumbs={[
{ label: 'Environments', link: 'portainer.endpoints' },
{
label: environment?.Name || '',
link: 'portainer.endpoints.endpoint',
linkParams: { id: environment?.Id },
},
'Kubernetes configuration',
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody>
<ConfigureForm />
</WidgetBody>
</Widget>
</div>
</div>
</>
);
}

View File

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

View File

@ -1,6 +1,20 @@
import { useQuery } from 'react-query';
import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
export function useIsRBACEnabledQuery(environmentId: EnvironmentId) {
return useQuery<boolean, Error>(
['environments', environmentId, 'rbacEnabled'],
() => getIsRBACEnabled(environmentId),
{
enabled: !!environmentId,
...withError('Unable to check if RBAC is enabled.'),
}
);
}
export async function getIsRBACEnabled(environmentId: EnvironmentId) {
try {

View File

@ -10,7 +10,7 @@ import { createPersistedStore } from '@@/datatables/types';
import { buildConfirmButton } from '@@/modals/utils';
import { useTableState } from '@@/datatables/useTableState';
import { IngressControllerClassMap } from '../types';
import { IngressControllerClassMapRowData } from '../types';
import { columns } from './columns';
@ -19,10 +19,11 @@ const settingsStore = createPersistedStore(storageKey, 'name');
interface Props {
onChangeControllers: (
controllerClassMap: IngressControllerClassMap[]
controllerClassMap: IngressControllerClassMapRowData[]
) => void; // angular function to save the ingress class list
description: string;
ingressControllers: IngressControllerClassMap[] | undefined;
ingressControllers: IngressControllerClassMapRowData[] | undefined;
initialIngressControllers: IngressControllerClassMapRowData[] | undefined;
allowNoneIngressClass: boolean;
isLoading: boolean;
noIngressControllerLabel: string;
@ -32,6 +33,7 @@ interface Props {
export function IngressClassDatatable({
onChangeControllers,
description,
initialIngressControllers,
ingressControllers,
allowNoneIngressClass,
isLoading,
@ -44,12 +46,23 @@ export function IngressClassDatatable({
ingressControllers || []
);
// set the ingress controller form values when the ingress controller list changes
// and the ingress controller form values are not set
useEffect(() => {
if (allowNoneIngressClass === undefined) {
if (
ingressControllers &&
ingControllerFormValues.length !== ingressControllers.length
) {
setIngControllerFormValues(ingressControllers);
}
}, [ingressControllers, ingControllerFormValues]);
useEffect(() => {
if (allowNoneIngressClass === undefined || isLoading) {
return;
}
let newIngFormValues: IngressControllerClassMap[];
let newIngFormValues: IngressControllerClassMapRowData[];
const isCustomTypeExist = ingControllerFormValues.some(
(ic) => ic.Type === 'custom'
);
@ -93,7 +106,9 @@ export function IngressClassDatatable({
</div>
);
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
function renderTableActions(
selectedRows: IngressControllerClassMapRowData[]
) {
return (
<div className="flex items-start">
<ButtonGroup>
@ -140,9 +155,12 @@ export function IngressClassDatatable({
return (
<div className="text-muted flex w-full flex-col !text-xs">
<div className="mt-1">{description}</div>
{ingressControllers &&
{initialIngressControllers &&
ingControllerFormValues &&
isUnsavedChanges(ingressControllers, ingControllerFormValues) && (
isUnsavedChanges(
initialIngressControllers,
ingControllerFormValues
) && (
<span className="text-warning mt-1 flex items-center">
<Icon icon={AlertTriangle} className="!mr-1" />
<span className="text-warning">Unsaved changes.</span>
@ -153,8 +171,8 @@ export function IngressClassDatatable({
}
async function updateIngressControllers(
selectedRows: IngressControllerClassMap[],
ingControllerFormValues: IngressControllerClassMap[],
selectedRows: IngressControllerClassMapRowData[],
ingControllerFormValues: IngressControllerClassMapRowData[],
availability: boolean
) {
const updatedIngressControllers = getUpdatedIngressControllers(
@ -222,8 +240,8 @@ export function IngressClassDatatable({
}
function isUnsavedChanges(
oldIngressControllers: IngressControllerClassMap[],
newIngressControllers: IngressControllerClassMap[]
oldIngressControllers: IngressControllerClassMapRowData[],
newIngressControllers: IngressControllerClassMapRowData[]
) {
if (oldIngressControllers.length !== newIngressControllers.length) {
return true;
@ -240,8 +258,8 @@ function isUnsavedChanges(
}
function getUpdatedIngressControllers(
selectedRows: IngressControllerClassMap[],
allRows: IngressControllerClassMap[],
selectedRows: IngressControllerClassMapRowData[],
allRows: IngressControllerClassMapRowData[],
allow: boolean
) {
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);

View File

@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react';
import { Badge } from '@@/Badge';
import { Icon } from '@@/Icon';
import type { IngressControllerClassMap } from '../../types';
import type { IngressControllerClassMapRowData } from '../../types';
import { columnHelper } from './helper';
@ -16,7 +16,9 @@ export const availability = columnHelper.accessor('Availability', {
sortingFn: 'basic',
});
function Cell({ getValue }: CellContext<IngressControllerClassMap, boolean>) {
function Cell({
getValue,
}: CellContext<IngressControllerClassMapRowData, boolean>) {
const availability = getValue();
return (

View File

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

View File

@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table';
import { Badge } from '@@/Badge';
import type { IngressControllerClassMap } from '../../types';
import type { IngressControllerClassMapRowData } from '../../types';
import { columnHelper } from './helper';
@ -15,7 +15,7 @@ export const name = columnHelper.accessor('ClassName', {
function NameCell({
row,
getValue,
}: CellContext<IngressControllerClassMap, string>) {
}: CellContext<IngressControllerClassMapRowData, string>) {
const className = getValue();
return (

View File

@ -6,3 +6,24 @@ import {
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export type SupportedIngControllerTypes =
| 'nginx'
| 'traefik'
| 'other'
| 'custom';
// Not having 'extends Record<string, unknown>' fixes validation type errors from yup
export interface IngressControllerClassMap {
Name: string;
ClassName: string;
Type: string;
Availability: boolean;
New: boolean;
Used: boolean; // if the controller is used by any ingress in the cluster
}
// Record<string, unknown> fixes type errors when using the type with a react datatable
export interface IngressControllerClassMapRowData
extends Record<string, unknown>,
IngressControllerClassMap {}

View File

@ -1,8 +1,45 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { IngressControllerClassMap } from '../types';
import { IngressControllerClassMapRowData } from './types';
export function useIngressControllerClassMapQuery({
environmentId,
namespace,
allowedOnly,
}: {
environmentId?: EnvironmentId;
namespace?: string;
allowedOnly?: boolean;
}) {
return useQuery(
[
'environments',
environmentId,
'ingresscontrollers',
namespace,
allowedOnly,
],
() => {
if (!environmentId) {
return [];
}
return getIngressControllerClassMap({
environmentId,
namespace,
allowedOnly,
});
},
{
...withError('Failure', 'Unable to get ingress controllers.'),
enabled: !!environmentId,
}
);
}
// get all supported ingress classes and controllers for the cluster
// allowedOnly set to true will hide globally disallowed ingresscontrollers
@ -17,7 +54,7 @@ export async function getIngressControllerClassMap({
}) {
try {
const { data: controllerMaps } = await axios.get<
IngressControllerClassMap[]
IngressControllerClassMapRowData[]
>(
buildUrl(environmentId, namespace),
allowedOnly ? { params: { allowedOnly: true } } : undefined
@ -31,12 +68,12 @@ export async function getIngressControllerClassMap({
// get all supported ingress classes and controllers for the cluster
export async function updateIngressControllerClassMap(
environmentId: EnvironmentId,
ingressControllerClassMap: IngressControllerClassMap[],
ingressControllerClassMap: IngressControllerClassMapRowData[],
namespace?: string
) {
try {
const { data: controllerMaps } = await axios.put<
IngressControllerClassMap[]
IngressControllerClassMapRowData[]
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
return controllerMaps;
} catch (e) {

View File

@ -4,11 +4,17 @@ export type SupportedIngControllerTypes =
| 'other'
| 'custom';
export interface IngressControllerClassMap extends Record<string, unknown> {
// Not having 'extends Record<string, unknown>' fixes validation type errors from yup
export interface IngressControllerClassMap {
Name: string;
ClassName: string;
Type: SupportedIngControllerTypes;
Type: string;
Availability: boolean;
New: boolean;
Used: boolean; // if the controller is used by any ingress in the cluster
}
// Record<string, unknown> fixes type errors when using the type with a react datatable
export interface IngressControllerClassMapRowData
extends Record<string, unknown>,
IngressControllerClassMap {}

View File

@ -0,0 +1,94 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { IngressControllerClassMapRowData } from './types';
export function useIngressControllerClassMapQuery({
environmentId,
namespace,
allowedOnly,
}: {
environmentId?: EnvironmentId;
namespace?: string;
allowedOnly?: boolean;
}) {
return useQuery(
[
'environments',
environmentId,
'ingresscontrollers',
namespace,
allowedOnly,
],
() => {
if (!environmentId) {
return [];
}
return getIngressControllerClassMap({
environmentId,
namespace,
allowedOnly,
});
},
{
...withError('Failure', 'Unable to get ingress controllers.'),
enabled: !!environmentId,
}
);
}
// get all supported ingress classes and controllers for the cluster
// allowedOnly set to true will hide globally disallowed ingresscontrollers
export async function getIngressControllerClassMap({
environmentId,
namespace,
allowedOnly,
}: {
environmentId: EnvironmentId;
namespace?: string;
allowedOnly?: boolean;
}) {
try {
const { data: controllerMaps } = await axios.get<
IngressControllerClassMapRowData[]
>(
buildUrl(environmentId, namespace),
allowedOnly ? { params: { allowedOnly: true } } : undefined
);
return controllerMaps;
} catch (e) {
throw new PortainerError('Unable to get ingress controllers.', e as Error);
}
}
// get all supported ingress classes and controllers for the cluster
export async function updateIngressControllerClassMap(
environmentId: EnvironmentId,
ingressControllerClassMap: IngressControllerClassMapRowData[],
namespace?: string
) {
try {
const { data: controllerMaps } = await axios.put<
IngressControllerClassMapRowData[]
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
return controllerMaps;
} catch (e) {
throw new PortainerError(
'Unable to update ingress controllers.',
e as Error
);
}
}
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
let url = `kubernetes/${environmentId}/`;
if (namespace) {
url += `namespaces/${namespace}/`;
}
url += 'ingresscontrollers';
return url;
}

View File

@ -0,0 +1,8 @@
import { useMutation } from 'react-query';
import { getMetricsForAllNodes } from '../services/service';
// use this as a mutation because the metrics request should be manually fired when the user clicks to turn the metrics toggle on
export function useGetMetricsMutation() {
return useMutation(getMetricsForAllNodes);
}

View File

@ -0,0 +1,109 @@
import { ChevronUp, ChevronDown } from 'lucide-react';
import moment from 'moment';
import { Button } from '@@/buttons';
import { Input } from '@@/form-components/Input';
import { utcToTimeZone } from './utils';
const valueFormat = 'HH:mm';
const displayFormat = 'hh:mm';
const minuteIncrement = 5;
type Props = {
utcTime: string;
onChange: (time: string) => void;
timeZone?: string;
};
export function TimePickerInput({
utcTime,
onChange,
timeZone = 'UTC',
}: Props) {
const localTime12h = utcToTimeZone(utcTime, timeZone, displayFormat);
const localTime24h = utcToTimeZone(utcTime, timeZone, valueFormat);
const [hours, minutes] = localTime12h.split(':');
return (
<div className="flex items-center">
<div className="flex flex-col">
<Button
color="link"
size="medium"
className="!ml-0 w-full"
icon={ChevronUp}
onClick={() => {
const newTime = moment(localTime24h, valueFormat)
.add(1, 'hours')
.format(valueFormat);
onChange(newTime);
}}
/>
<Input
type="text"
value={hours}
className="w-12 !cursor-default text-center"
disabled
/>
<Button
color="link"
size="medium"
className="!ml-0 w-full"
icon={ChevronDown}
onClick={() => {
const newTime = moment(localTime24h, valueFormat)
.subtract(1, 'hours')
.format(valueFormat);
onChange(newTime);
}}
/>
</div>
:
<div className="flex flex-col">
<Button
color="link"
size="medium"
className="!ml-0 w-full"
icon={ChevronUp}
onClick={() => {
const newTime = moment(localTime24h, valueFormat)
.add(minuteIncrement, 'minutes')
.format(valueFormat);
onChange(newTime);
}}
/>
<Input
type="text"
value={minutes}
className="w-12 !cursor-default text-center"
disabled
/>
<Button
color="link"
size="medium"
className="!ml-0 w-full"
icon={ChevronDown}
onClick={() => {
const newTime = moment(localTime24h, valueFormat)
.subtract(minuteIncrement, 'minutes')
.format(valueFormat);
onChange(newTime);
}}
/>
</div>
<Button
color="default"
className="h-[34px]"
onClick={() => {
const newTime = moment(localTime24h, valueFormat)
.add(12, 'hours')
.format(valueFormat);
onChange(newTime);
}}
>
{moment(localTime24h, valueFormat).format('A')}
</Button>
</div>
);
}

View File

@ -0,0 +1,99 @@
import moment from 'moment';
import { Button } from '@@/buttons';
import { Alert } from '@@/Alert';
import { EndpointChangeWindow } from '../../types';
import { TimeWindowPickerInputGroup } from './TimeWindowPickerInputGroup';
import { formatUTCTime, utcToTimeZone } from './utils';
type Props = {
/**
* The current start and end time values. in 'HH:mm' format (e.g. '00:00') and in UTC timezone.
*/
values: EndpointChangeWindow;
initialValues: EndpointChangeWindow;
onChange: ({
changeWindow,
timeZone,
}: {
changeWindow: EndpointChangeWindow;
timeZone?: string;
}) => void;
isEditMode: boolean;
setIsEditMode: (isEditMode: boolean) => void;
timeZone?: string;
initialTimeZone?: string;
};
const summaryTimeFormat = 'h:mmA';
export function TimeWindowPicker({
values,
initialValues,
onChange,
isEditMode,
setIsEditMode,
timeZone = moment.tz.guess(),
initialTimeZone,
}: Props) {
return (
<div className="mb-4 flex flex-col gap-y-2">
{isEditMode && (
<TimeWindowPickerInputGroup
values={values}
onChange={onChange}
timeZone={timeZone}
/>
)}
<Alert color="info" className="[&>div]:!text-xs">
<span>
GitOps updates to stacks or applications outside{' '}
<span className="font-bold">{`${formatUTCTime(
values.StartTime,
summaryTimeFormat
)} - ${formatUTCTime(
values.EndTime,
summaryTimeFormat
)} UTC (${utcToTimeZone(
values.StartTime,
timeZone,
summaryTimeFormat
)} - ${utcToTimeZone(values.EndTime, timeZone, summaryTimeFormat)} ${
moment().isDST() ? ' DST' : ''
} ${timeZone})`}</span>{' '}
will not occur.
</span>
</Alert>
{values.Enabled && (
<div className="flex w-full">
{!isEditMode && (
<Button
color="default"
className="!ml-0"
onClick={() => setIsEditMode(true)}
>
Edit Change Window
</Button>
)}
{isEditMode && (
<Button
color="default"
className="!ml-0"
onClick={() => {
setIsEditMode(false);
onChange({
changeWindow: initialValues,
timeZone: initialTimeZone,
});
}}
>
Cancel
</Button>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,130 @@
import moment from 'moment';
import { useMemo } from 'react';
import { Select } from '@@/form-components/ReactSelect';
import { Option } from '@@/form-components/PortainerSelect';
import { EndpointChangeWindow } from '../../types';
import { timeZoneToUtc, utcToTimeZone } from './utils';
import { TimePickerInput } from './TimePickerInput';
type Props = {
/**
* The current start and end time values. in 'HH:mm' format (e.g. '00:00') and in UTC timezone.
*/
values: EndpointChangeWindow;
onChange: ({
changeWindow,
timeZone,
}: {
changeWindow: EndpointChangeWindow;
timeZone: string;
}) => void;
timeZone?: string;
};
export function TimeWindowPickerInputGroup({
values,
onChange,
timeZone = moment.tz.guess(),
}: Props) {
// all unique timezones for all countries as options
const timeZoneOptions = useMemo(() => {
const countries = moment.tz.countries();
const zones = countries.flatMap((country) =>
moment.tz.zonesForCountry(country)
);
return [...new Set(zones)]
.sort()
.concat('UTC')
.map((zone) => ({
label: zone,
value: zone,
}));
}, []);
// set the initial timezone to the user's timezone if it is not set
if (!timeZone) {
const newTimeZone = moment.tz.guess();
onChange({
changeWindow: {
...values,
StartTime: timeZoneToUtc(values.StartTime, newTimeZone),
EndTime: timeZoneToUtc(values.EndTime, newTimeZone),
},
timeZone: newTimeZone,
});
}
return (
<div className="flex flex-wrap items-center gap-x-5">
<div className="flex items-center gap-x-5">
<TimePickerInput
utcTime={values.StartTime}
timeZone={timeZone}
onChange={(time) =>
onChange({
changeWindow: {
...values,
StartTime: timeZoneToUtc(time, timeZone),
},
timeZone,
})
}
/>
to
<TimePickerInput
utcTime={values.EndTime}
timeZone={timeZone}
onChange={(time) =>
onChange({
changeWindow: {
...values,
EndTime: timeZoneToUtc(time, timeZone),
},
timeZone,
})
}
/>
</div>
<Select<Option<string>>
options={timeZoneOptions}
value={{ value: timeZone, label: timeZone }}
className="w-72 min-w-fit"
onChange={(newTimeZone) => {
if (!newTimeZone) return;
// update the utc time so that the local time displayed remains the same
const updatedStartTime = onTimezoneChangeUpdateUTCTime(
values.StartTime,
timeZone,
newTimeZone.value
);
const updatedEndTime = onTimezoneChangeUpdateUTCTime(
values.EndTime,
timeZone,
newTimeZone.value
);
onChange({
changeWindow: {
...values,
StartTime: updatedStartTime,
EndTime: updatedEndTime,
},
timeZone: newTimeZone.value,
});
}}
/>
</div>
);
}
function onTimezoneChangeUpdateUTCTime(
utcTime: string,
oldTimeZone: string,
newTimeZone: string
) {
const localTime = utcToTimeZone(utcTime, oldTimeZone);
const newUtcTime = timeZoneToUtc(localTime, newTimeZone);
return newUtcTime;
}

View File

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

View File

@ -0,0 +1,36 @@
import moment from 'moment';
/**
* Converts a UTC time to the same format in the given timezone.
* @param utcTime The UTC time to convert in 'HH:mm' format.
* @param timeZone The timezone to convert the UTC time to.
* @param format The format to convert the time to.
* @returns The converted time in the same format as the input.
*/
export function utcToTimeZone(
utcTime: string,
timeZone: string,
format = 'HH:mm'
) {
return moment.utc(utcTime, 'HH:mm').tz(timeZone).format(format);
}
/**
* Converts a time in the given timezone to the same format in UTC.
* @param time The time to convert in 'HH:mm' format.
* @param timeZone The timezone to convert the time to UTC.
* @returns The converted time in the same format as the input.
*/
export function timeZoneToUtc(time: string, timeZone: string) {
return moment.tz(time, 'HH:mm', timeZone).utc().format('HH:mm');
}
/**
* Formats a UTC time string to the specified format.
* @param utcTime - The UTC time string to format in 'HH:mm' format.
* @param format - The format to use. Defaults to 'HH:mm'.
* @returns The formatted time string.
*/
export function formatUTCTime(utcTime: string, format = 'HH:mm') {
return moment.utc(utcTime, 'HH:mm').format(format);
}

View File

@ -1,8 +1,8 @@
import { EnvironmentId } from '../types';
export const queryKeys = {
export const environmentQueryKeys = {
base: () => ['environments'] as const,
item: (id: EnvironmentId) => [...queryKeys.base(), id] as const,
item: (id: EnvironmentId) => [...environmentQueryKeys.base(), id] as const,
registries: (environmentId: EnvironmentId) =>
[...queryKeys.base(), environmentId, 'registries'] as const,
[...environmentQueryKeys.base(), environmentId, 'registries'] as const,
};

View File

@ -2,10 +2,10 @@ import { useQuery } from 'react-query';
import { getAgentVersions } from '../environment.service';
import { queryKeys } from './query-keys';
import { environmentQueryKeys } from './query-keys';
export function useAgentVersionsList() {
return useQuery([...queryKeys.base(), 'agentVersions'], () =>
return useQuery([...environmentQueryKeys.base(), 'agentVersions'], () =>
getAgentVersions()
);
}

View File

@ -7,14 +7,14 @@ import {
} from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { queryKeys } from './query-keys';
import { environmentQueryKeys } from './query-keys';
export function useEnvironment<T = Environment | null>(
id?: EnvironmentId,
select?: (environment: Environment | null) => T
) {
return useQuery(
id ? queryKeys.item(id) : [],
id ? environmentQueryKeys.item(id) : [],
() => (id ? getEndpoint(id) : null),
{
select,

View File

@ -8,7 +8,7 @@ import {
getEnvironments,
} from '../environment.service';
import { queryKeys } from './query-keys';
import { environmentQueryKeys } from './query-keys';
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
@ -60,7 +60,7 @@ export function useEnvironmentList(
) {
const { isLoading, data } = useQuery(
[
...queryKeys.base(),
...environmentQueryKeys.base(),
{
page,
pageLimit,

View File

@ -5,14 +5,14 @@ import { EnvironmentId } from '../types';
import { Registry } from '../../registries/types/registry';
import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries';
import { queryKeys } from './query-keys';
import { environmentQueryKeys } from './query-keys';
export function useEnvironmentRegistries<T = Array<Registry>>(
environmentId: EnvironmentId,
queryOptions: { select?(data: Array<Registry>): T; enabled?: boolean } = {}
) {
return useGenericRegistriesQuery(
queryKeys.registries(environmentId),
environmentQueryKeys.registries(environmentId),
() => getEnvironmentRegistries(environmentId),
queryOptions
);

View File

@ -5,6 +5,9 @@ import {
EnvironmentId,
EnvironmentStatusMessage,
Environment,
KubernetesSettings,
DeploymentOptions,
EndpointChangeWindow,
} from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
@ -12,17 +15,17 @@ import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '../environment-groups/types';
import { buildUrl } from '../environment.service/utils';
import { queryKeys } from './query-keys';
import { environmentQueryKeys } from './query-keys';
export function useUpdateEnvironmentMutation() {
const queryClient = useQueryClient();
return useMutation(updateEnvironment, {
...withInvalidate(queryClient, [queryKeys.base()]),
...withInvalidate(queryClient, [environmentQueryKeys.base()]),
...withError('Unable to update environment'),
});
}
export interface UpdatePayload {
export interface UpdateEnvironmentPayload extends Partial<Environment> {
TLSCACert?: File;
TLSCert?: File;
TLSKey?: File;
@ -42,15 +45,18 @@ export interface UpdatePayload {
AzureAuthenticationKey: string;
IsSetStatusMessage: boolean;
StatusMessage: Partial<EnvironmentStatusMessage>;
StatusMessage: EnvironmentStatusMessage;
Kubernetes?: KubernetesSettings;
DeploymentOptions?: DeploymentOptions | null;
ChangeWindow?: EndpointChangeWindow;
}
async function updateEnvironment({
export async function updateEnvironment({
id,
payload,
}: {
id: EnvironmentId;
payload: Partial<UpdatePayload>;
payload: Partial<UpdateEnvironmentPayload>;
}) {
try {
await uploadTLSFilesForEndpoint(

View File

@ -16,7 +16,7 @@ import { EnvironmentId } from '../types';
import { buildUrl } from '../environment.service/utils';
import { EnvironmentGroupId } from '../environment-groups/types';
import { queryKeys } from './query-keys';
import { environmentQueryKeys } from './query-keys';
export function useUpdateEnvironmentsRelationsMutation() {
const queryClient = useQueryClient();
@ -25,7 +25,7 @@ export function useUpdateEnvironmentsRelationsMutation() {
updateEnvironmentRelations,
mutationOptions(
withInvalidate(queryClient, [
queryKeys.base(),
environmentQueryKeys.base(),
edgeGroupQueryKeys.base(),
groupQueryKeys.base(),
tagKeys.all,

View File

@ -127,7 +127,7 @@ export type DeploymentOptions = {
/**
* EndpointChangeWindow determine when GitOps stack/app updates may occur
*/
interface EndpointChangeWindow {
export interface EndpointChangeWindow {
Enabled: boolean;
StartTime: string;
EndTime: string;

View File

@ -3,7 +3,7 @@ import { useRouter } from '@uirouter/react';
import _ from 'lodash';
import { Wand2 } from 'lucide-react';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { Button } from '@@/buttons';
import { PageHeader } from '@@/PageHeader';

View File

@ -9,7 +9,7 @@ import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { Stepper } from '@@/Stepper';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';

View File

@ -1,7 +1,7 @@
import { Wand2, Plug2 } from 'lucide-react';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import Kube from '@/assets/ico/kube.svg?c';

View File

@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
import { Terminal } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';

View File

@ -1,4 +1,4 @@
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { HubspotForm } from '@@/HubspotForm';
import { Modal } from '@@/modals/Modal';

View File

@ -2,7 +2,7 @@ import { ArrowUpCircle } from 'lucide-react';
import { useState } from 'react';
import clsx from 'clsx';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { useNodesCount } from '@/react/portainer/system/useNodesCount';
import {
ContainerPlatform,

View File

@ -3,7 +3,7 @@ import { object, SchemaOf, string } from 'yup';
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { Button, LoadingButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';