mirror of https://github.com/portainer/portainer
feat(k8sconfigure): migrate configure to react [EE-5524] (#10218)
parent
0f1e77a6d5
commit
515b02813b
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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))), [])
|
||||
|
|
|
@ -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 "none"'"
|
||||
tooltip="'This allows users setting up ingresses to select "none" 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 <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
|
||||
> with the <code class="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">--authorization-mode</code> flag set to a
|
||||
comma-separated list that includes <code class="bg-gray-4 th-highcontrast:bg-black th-dark:bg-black">RBAC</code>, for example:
|
||||
<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>
|
|
@ -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);
|
|
@ -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'"
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -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 } });
|
||||
|
|
|
@ -16,4 +16,5 @@ export const switchField = r2a(SwitchField, [
|
|||
'featureId',
|
||||
'switchClass',
|
||||
'setTooltipHtmlMessage',
|
||||
'valueExplanation',
|
||||
]);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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'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);
|
||||
}
|
|
@ -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
|
||||
<a
|
||||
href="https://kubernetes.io/docs/tasks/debug-application-cluster/resource-metrics-pipeline/#metrics-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
metrics server
|
||||
</a>
|
||||
or
|
||||
<a
|
||||
href="https://github.com/kubernetes-sigs/prometheus-adapter"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
prometheus
|
||||
</a>
|
||||
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>
|
||||
);
|
||||
}
|
|
@ -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't use Portainer RBAC functionality to</p>
|
||||
<p className="mb-0">
|
||||
To enable RBAC, start the
|
||||
<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>
|
||||
with the
|
||||
<code className="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">
|
||||
--authorization-mode
|
||||
</code>
|
||||
flag set to a comma-separated list that includes
|
||||
<code className="bg-gray-4 th-highcontrast:bg-black th-dark:bg-black">
|
||||
RBAC
|
||||
</code>
|
||||
, for example:
|
||||
<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>
|
||||
);
|
||||
}
|
|
@ -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}`}
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ConfigureForm } from './ConfigureForm';
|
|
@ -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[];
|
||||
};
|
|
@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(),
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ConfigureView } from './ConfigureView';
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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) {
|
|
@ -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 {}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { TimeWindowPicker } from './TimeWindowPicker';
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue