mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
feat(k8sconfigure): migrate configure to react [EE-5524] (#10218)
This commit is contained in:
parent
0f1e77a6d5
commit
515b02813b
59 changed files with 1819 additions and 833 deletions
|
@ -1,7 +1,5 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
'docker',
|
'docker',
|
||||||
'kubernetes',
|
'kubernetes',
|
||||||
|
@ -18,7 +16,7 @@ enum DimensionConfig {
|
||||||
PortainerEndpointUserRole,
|
PortainerEndpointUserRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackEventProps {
|
export interface TrackEventProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
value?: string | number;
|
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) {
|
export function trackEvent(action: string, properties: TrackEventProps) {
|
||||||
/**
|
/**
|
||||||
* @description Logs an event with an event category (Videos, Music, Games...), an event
|
* @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',
|
url: '/configure',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: './views/configure/configure.html',
|
component: 'kubernetesConfigureView',
|
||||||
controller: 'KubernetesConfigureController',
|
|
||||||
controllerAs: 'ctrl',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,7 +3,7 @@ import angular from 'angular';
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
|
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
|
||||||
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
|
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 { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||||
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
||||||
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
||||||
|
@ -30,6 +30,7 @@ export const ngModule = angular
|
||||||
'onChangeControllers',
|
'onChangeControllers',
|
||||||
'description',
|
'description',
|
||||||
'ingressControllers',
|
'ingressControllers',
|
||||||
|
'initialIngressControllers',
|
||||||
'allowNoneIngressClass',
|
'allowNoneIngressClass',
|
||||||
'isLoading',
|
'isLoading',
|
||||||
'noIngressControllerLabel',
|
'noIngressControllerLabel',
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { ServicesView } from '@/react/kubernetes/services/ServicesView';
|
||||||
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
|
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
|
||||||
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
|
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
|
||||||
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
|
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
|
||||||
|
import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.kubernetes.react.views', [])
|
.module('portainer.kubernetes.react.views', [])
|
||||||
|
@ -43,6 +44,10 @@ export const viewsModule = angular
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.component(
|
||||||
|
'kubernetesConfigureView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'kubernetesDashboardView',
|
'kubernetesDashboardView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])
|
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"
|
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
|
||||||
on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
|
on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
|
||||||
ingress-controllers="$ctrl.ingressControllers"
|
ingress-controllers="$ctrl.ingressControllers"
|
||||||
|
initial-ingress-controllers="$ctrl.initialIngressControllers"
|
||||||
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
|
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.'"
|
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
|
||||||
view="'namespace'"
|
view="'namespace'"
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
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 {
|
class KubernetesCreateResourcePoolController {
|
||||||
/* #region CONSTRUCTOR */
|
/* #region CONSTRUCTOR */
|
||||||
|
@ -201,6 +201,7 @@ class KubernetesCreateResourcePoolController {
|
||||||
this.ingressControllers = [];
|
this.ingressControllers = [];
|
||||||
if (this.state.ingressAvailabilityPerNamespace) {
|
if (this.state.ingressAvailabilityPerNamespace) {
|
||||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true });
|
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true });
|
||||||
|
this.initialIngressControllers = structuredClone(this.ingressControllers);
|
||||||
}
|
}
|
||||||
|
|
||||||
_.forEach(nodes, (item) => {
|
_.forEach(nodes, (item) => {
|
||||||
|
|
|
@ -174,6 +174,7 @@
|
||||||
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
|
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
|
||||||
on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
|
on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
|
||||||
ingress-controllers="ctrl.ingressControllers"
|
ingress-controllers="ctrl.ingressControllers"
|
||||||
|
initial-ingress-controllers="$ctrl.initialIngressControllers"
|
||||||
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
|
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.'"
|
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
|
||||||
view="'namespace'"
|
view="'namespace'"
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||||
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
|
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
|
||||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
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 { confirmUpdate } from '@@/modals/confirm';
|
||||||
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
|
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
|
||||||
import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
|
import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
|
||||||
|
@ -373,6 +373,7 @@ class KubernetesResourcePoolController {
|
||||||
this.ingressControllers = [];
|
this.ingressControllers = [];
|
||||||
if (this.state.ingressAvailabilityPerNamespace) {
|
if (this.state.ingressAvailabilityPerNamespace) {
|
||||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
|
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
|
||||||
|
this.initialIngressControllers = structuredClone(this.ingressControllers);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pool = _.find(pools, { Namespace: { Name: name } });
|
this.pool = _.find(pools, { Namespace: { Name: name } });
|
||||||
|
|
|
@ -16,4 +16,5 @@ export const switchField = r2a(SwitchField, [
|
||||||
'featureId',
|
'featureId',
|
||||||
'switchClass',
|
'switchClass',
|
||||||
'setTooltipHtmlMessage',
|
'setTooltipHtmlMessage',
|
||||||
|
'valueExplanation',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -43,23 +43,33 @@ const alertSettings: Record<
|
||||||
export function Alert({
|
export function Alert({
|
||||||
color,
|
color,
|
||||||
title,
|
title,
|
||||||
|
className,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<{ color: AlertType; title?: string }>) {
|
}: PropsWithChildren<{
|
||||||
|
color: AlertType;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}>) {
|
||||||
const { container, header, body, icon } = alertSettings[color];
|
const { container, header, body, icon } = alertSettings[color];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertContainer className={container}>
|
<AlertContainer className={clsx(container, className)}>
|
||||||
{title ? (
|
{title ? (
|
||||||
<>
|
<>
|
||||||
<AlertHeader className={header}>
|
<AlertHeader className={header}>
|
||||||
<Icon icon={icon} />
|
<Icon icon={icon} />
|
||||||
{title}
|
{title}
|
||||||
</AlertHeader>
|
</AlertHeader>
|
||||||
<AlertBody className={body}>{children}</AlertBody>
|
<AlertBody className={body} hasTitle={!!title}>
|
||||||
|
{children}
|
||||||
|
</AlertBody>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<AlertBody className={clsx(body, 'flex items-center gap-2')}>
|
<AlertBody
|
||||||
<Icon icon={icon} /> {children}
|
className={clsx(body, 'flex items-start gap-2')}
|
||||||
|
hasTitle={!!title}
|
||||||
|
>
|
||||||
|
<Icon icon={icon} className="!mt-0.5 flex-none" /> {children}
|
||||||
</AlertBody>
|
</AlertBody>
|
||||||
)}
|
)}
|
||||||
</AlertContainer>
|
</AlertContainer>
|
||||||
|
@ -96,7 +106,12 @@ function AlertHeader({
|
||||||
|
|
||||||
function AlertBody({
|
function AlertBody({
|
||||||
className,
|
className,
|
||||||
|
hasTitle,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<{ className?: string }>) {
|
}: PropsWithChildren<{ className?: string; hasTitle: boolean }>) {
|
||||||
return <div className={clsx('ml-6 text-sm', className)}>{children}</div>;
|
return (
|
||||||
|
<div className={clsx('text-sm', className, { 'ml-6': hasTitle })}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ interface Props {
|
||||||
loadingText: string;
|
loadingText: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
|
'data-cy'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormActions({
|
export function FormActions({
|
||||||
|
@ -15,6 +16,7 @@ export function FormActions({
|
||||||
isLoading,
|
isLoading,
|
||||||
children,
|
children,
|
||||||
isValid,
|
isValid,
|
||||||
|
'data-cy': dataCy,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
@ -24,6 +26,7 @@ export function FormActions({
|
||||||
loadingText={loadingText}
|
loadingText={loadingText}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
|
data-cy={dataCy}
|
||||||
>
|
>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ComponentProps } from 'react';
|
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ export interface Props {
|
||||||
dataCy?: string;
|
dataCy?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
featureId?: FeatureId;
|
featureId?: FeatureId;
|
||||||
|
valueExplanation?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchField({
|
export function SwitchField({
|
||||||
|
@ -40,7 +41,8 @@ export function SwitchField({
|
||||||
featureId,
|
featureId,
|
||||||
switchClass,
|
switchClass,
|
||||||
setTooltipHtmlMessage,
|
setTooltipHtmlMessage,
|
||||||
}: Props) {
|
valueExplanation,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
const toggleName = name ? `toggle_${name}` : '';
|
const toggleName = name ? `toggle_${name}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -65,6 +67,7 @@ export function SwitchField({
|
||||||
featureId={featureId}
|
featureId={featureId}
|
||||||
dataCy={dataCy}
|
dataCy={dataCy}
|
||||||
/>
|
/>
|
||||||
|
{valueExplanation && <span>{valueExplanation}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from '@/react-tools/react-query';
|
} from '@/react-tools/react-query';
|
||||||
import { queryKey as nodesCountQueryKey } from '@/react/portainer/system/useNodesCount';
|
import { queryKey as nodesCountQueryKey } from '@/react/portainer/system/useNodesCount';
|
||||||
import { LicenseType } from '@/react/portainer/licenses/types';
|
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() {
|
export function useAssociateDeviceMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -22,7 +22,10 @@ export function useAssociateDeviceMutation() {
|
||||||
promiseSequence(ids.map((id) => () => associateDevice(id))),
|
promiseSequence(ids.map((id) => () => associateDevice(id))),
|
||||||
mutationOptions(
|
mutationOptions(
|
||||||
withError('Failed to associate devices'),
|
withError('Failed to associate devices'),
|
||||||
withInvalidate(queryClient, [queryKeys.base(), nodesCountQueryKey])
|
withInvalidate(queryClient, [
|
||||||
|
environmentQueryKeys.base(),
|
||||||
|
nodesCountQueryKey,
|
||||||
|
])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
16
app/react/hooks/useAnalytics.ts
Normal file
16
app/react/hooks/useAnalytics.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { trackEvent } from '@/angulartics.matomo/analytics-services';
|
||||||
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
|
|
||||||
|
export function useAnalytics() {
|
||||||
|
const telemetryQuery = usePublicSettings({
|
||||||
|
select: (settings) => settings.EnableTelemetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { trackEvent: handleTrackEvent };
|
||||||
|
|
||||||
|
function handleTrackEvent(...args: Parameters<typeof trackEvent>) {
|
||||||
|
if (telemetryQuery.data) {
|
||||||
|
trackEvent(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
interface Props {
|
||||||
value: Option[];
|
value: Option[];
|
||||||
onChange(storageClassName: string, value: readonly Option[]): void;
|
onChange(value: readonly Option[]): void;
|
||||||
options: Option[];
|
options: Option[];
|
||||||
inputId?: string;
|
inputId?: string;
|
||||||
storageClassName: string;
|
storageClassName: string;
|
||||||
|
@ -31,7 +31,7 @@ export function StorageAccessModeSelector({
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
closeMenuOnSelect={false}
|
closeMenuOnSelect={false}
|
||||||
onChange={(value) => onChange(storageClassName, value)}
|
onChange={(value) => onChange(value)}
|
||||||
inputId={inputId}
|
inputId={inputId}
|
||||||
placeholder="Not configured"
|
placeholder="Not configured"
|
||||||
data-cy={`kubeSetup-storageAccessSelect${storageClassName}`}
|
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(),
|
||||||
|
});
|
39
app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx
Normal file
39
app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
import { Widget, WidgetBody } from '@@/Widget';
|
||||||
|
|
||||||
|
import { ConfigureForm } from './ConfigureForm';
|
||||||
|
|
||||||
|
export function ConfigureView() {
|
||||||
|
const { data: environment } = useCurrentEnvironment();
|
||||||
|
|
||||||
|
// get the initial values
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Kubernetes features configuration"
|
||||||
|
reload
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Environments', link: 'portainer.endpoints' },
|
||||||
|
{
|
||||||
|
label: environment?.Name || '',
|
||||||
|
link: 'portainer.endpoints.endpoint',
|
||||||
|
linkParams: { id: environment?.Id },
|
||||||
|
},
|
||||||
|
'Kubernetes configuration',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<WidgetBody>
|
||||||
|
<ConfigureForm />
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
1
app/react/kubernetes/cluster/ConfigureView/index.ts
Normal file
1
app/react/kubernetes/cluster/ConfigureView/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { ConfigureView } from './ConfigureView';
|
|
@ -1,6 +1,20 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
import PortainerError from '@/portainer/error';
|
import PortainerError from '@/portainer/error';
|
||||||
import axios from '@/portainer/services/axios';
|
import axios from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
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) {
|
export async function getIsRBACEnabled(environmentId: EnvironmentId) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { createPersistedStore } from '@@/datatables/types';
|
||||||
import { buildConfirmButton } from '@@/modals/utils';
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
|
|
||||||
import { IngressControllerClassMap } from '../types';
|
import { IngressControllerClassMapRowData } from '../types';
|
||||||
|
|
||||||
import { columns } from './columns';
|
import { columns } from './columns';
|
||||||
|
|
||||||
|
@ -19,10 +19,11 @@ const settingsStore = createPersistedStore(storageKey, 'name');
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onChangeControllers: (
|
onChangeControllers: (
|
||||||
controllerClassMap: IngressControllerClassMap[]
|
controllerClassMap: IngressControllerClassMapRowData[]
|
||||||
) => void; // angular function to save the ingress class list
|
) => void; // angular function to save the ingress class list
|
||||||
description: string;
|
description: string;
|
||||||
ingressControllers: IngressControllerClassMap[] | undefined;
|
ingressControllers: IngressControllerClassMapRowData[] | undefined;
|
||||||
|
initialIngressControllers: IngressControllerClassMapRowData[] | undefined;
|
||||||
allowNoneIngressClass: boolean;
|
allowNoneIngressClass: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
noIngressControllerLabel: string;
|
noIngressControllerLabel: string;
|
||||||
|
@ -32,6 +33,7 @@ interface Props {
|
||||||
export function IngressClassDatatable({
|
export function IngressClassDatatable({
|
||||||
onChangeControllers,
|
onChangeControllers,
|
||||||
description,
|
description,
|
||||||
|
initialIngressControllers,
|
||||||
ingressControllers,
|
ingressControllers,
|
||||||
allowNoneIngressClass,
|
allowNoneIngressClass,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
@ -44,12 +46,23 @@ export function IngressClassDatatable({
|
||||||
ingressControllers || []
|
ingressControllers || []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// set the ingress controller form values when the ingress controller list changes
|
||||||
|
// and the ingress controller form values are not set
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowNoneIngressClass === undefined) {
|
if (
|
||||||
|
ingressControllers &&
|
||||||
|
ingControllerFormValues.length !== ingressControllers.length
|
||||||
|
) {
|
||||||
|
setIngControllerFormValues(ingressControllers);
|
||||||
|
}
|
||||||
|
}, [ingressControllers, ingControllerFormValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allowNoneIngressClass === undefined || isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newIngFormValues: IngressControllerClassMap[];
|
let newIngFormValues: IngressControllerClassMapRowData[];
|
||||||
const isCustomTypeExist = ingControllerFormValues.some(
|
const isCustomTypeExist = ingControllerFormValues.some(
|
||||||
(ic) => ic.Type === 'custom'
|
(ic) => ic.Type === 'custom'
|
||||||
);
|
);
|
||||||
|
@ -93,7 +106,9 @@ export function IngressClassDatatable({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
|
function renderTableActions(
|
||||||
|
selectedRows: IngressControllerClassMapRowData[]
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
|
@ -140,9 +155,12 @@ export function IngressClassDatatable({
|
||||||
return (
|
return (
|
||||||
<div className="text-muted flex w-full flex-col !text-xs">
|
<div className="text-muted flex w-full flex-col !text-xs">
|
||||||
<div className="mt-1">{description}</div>
|
<div className="mt-1">{description}</div>
|
||||||
{ingressControllers &&
|
{initialIngressControllers &&
|
||||||
ingControllerFormValues &&
|
ingControllerFormValues &&
|
||||||
isUnsavedChanges(ingressControllers, ingControllerFormValues) && (
|
isUnsavedChanges(
|
||||||
|
initialIngressControllers,
|
||||||
|
ingControllerFormValues
|
||||||
|
) && (
|
||||||
<span className="text-warning mt-1 flex items-center">
|
<span className="text-warning mt-1 flex items-center">
|
||||||
<Icon icon={AlertTriangle} className="!mr-1" />
|
<Icon icon={AlertTriangle} className="!mr-1" />
|
||||||
<span className="text-warning">Unsaved changes.</span>
|
<span className="text-warning">Unsaved changes.</span>
|
||||||
|
@ -153,8 +171,8 @@ export function IngressClassDatatable({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateIngressControllers(
|
async function updateIngressControllers(
|
||||||
selectedRows: IngressControllerClassMap[],
|
selectedRows: IngressControllerClassMapRowData[],
|
||||||
ingControllerFormValues: IngressControllerClassMap[],
|
ingControllerFormValues: IngressControllerClassMapRowData[],
|
||||||
availability: boolean
|
availability: boolean
|
||||||
) {
|
) {
|
||||||
const updatedIngressControllers = getUpdatedIngressControllers(
|
const updatedIngressControllers = getUpdatedIngressControllers(
|
||||||
|
@ -222,8 +240,8 @@ export function IngressClassDatatable({
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUnsavedChanges(
|
function isUnsavedChanges(
|
||||||
oldIngressControllers: IngressControllerClassMap[],
|
oldIngressControllers: IngressControllerClassMapRowData[],
|
||||||
newIngressControllers: IngressControllerClassMap[]
|
newIngressControllers: IngressControllerClassMapRowData[]
|
||||||
) {
|
) {
|
||||||
if (oldIngressControllers.length !== newIngressControllers.length) {
|
if (oldIngressControllers.length !== newIngressControllers.length) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -240,8 +258,8 @@ function isUnsavedChanges(
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUpdatedIngressControllers(
|
function getUpdatedIngressControllers(
|
||||||
selectedRows: IngressControllerClassMap[],
|
selectedRows: IngressControllerClassMapRowData[],
|
||||||
allRows: IngressControllerClassMap[],
|
allRows: IngressControllerClassMapRowData[],
|
||||||
allow: boolean
|
allow: boolean
|
||||||
) {
|
) {
|
||||||
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
|
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react';
|
||||||
import { Badge } from '@@/Badge';
|
import { Badge } from '@@/Badge';
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
import type { IngressControllerClassMap } from '../../types';
|
import type { IngressControllerClassMapRowData } from '../../types';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
@ -16,7 +16,9 @@ export const availability = columnHelper.accessor('Availability', {
|
||||||
sortingFn: 'basic',
|
sortingFn: 'basic',
|
||||||
});
|
});
|
||||||
|
|
||||||
function Cell({ getValue }: CellContext<IngressControllerClassMap, boolean>) {
|
function Cell({
|
||||||
|
getValue,
|
||||||
|
}: CellContext<IngressControllerClassMapRowData, boolean>) {
|
||||||
const availability = getValue();
|
const availability = getValue();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { createColumnHelper } from '@tanstack/react-table';
|
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 { Badge } from '@@/Badge';
|
||||||
|
|
||||||
import type { IngressControllerClassMap } from '../../types';
|
import type { IngressControllerClassMapRowData } from '../../types';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ export const name = columnHelper.accessor('ClassName', {
|
||||||
function NameCell({
|
function NameCell({
|
||||||
row,
|
row,
|
||||||
getValue,
|
getValue,
|
||||||
}: CellContext<IngressControllerClassMap, string>) {
|
}: CellContext<IngressControllerClassMapRowData, string>) {
|
||||||
const className = getValue();
|
const className = getValue();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -6,3 +6,24 @@ import {
|
||||||
export interface TableSettings
|
export interface TableSettings
|
||||||
extends SortableTableSettings,
|
extends SortableTableSettings,
|
||||||
PaginationTableSettings {}
|
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 { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import PortainerError from '@/portainer/error';
|
import PortainerError from '@/portainer/error';
|
||||||
import axios from '@/portainer/services/axios';
|
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
|
// get all supported ingress classes and controllers for the cluster
|
||||||
// allowedOnly set to true will hide globally disallowed ingresscontrollers
|
// allowedOnly set to true will hide globally disallowed ingresscontrollers
|
||||||
|
@ -17,7 +54,7 @@ export async function getIngressControllerClassMap({
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const { data: controllerMaps } = await axios.get<
|
const { data: controllerMaps } = await axios.get<
|
||||||
IngressControllerClassMap[]
|
IngressControllerClassMapRowData[]
|
||||||
>(
|
>(
|
||||||
buildUrl(environmentId, namespace),
|
buildUrl(environmentId, namespace),
|
||||||
allowedOnly ? { params: { allowedOnly: true } } : undefined
|
allowedOnly ? { params: { allowedOnly: true } } : undefined
|
||||||
|
@ -31,12 +68,12 @@ export async function getIngressControllerClassMap({
|
||||||
// get all supported ingress classes and controllers for the cluster
|
// get all supported ingress classes and controllers for the cluster
|
||||||
export async function updateIngressControllerClassMap(
|
export async function updateIngressControllerClassMap(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
ingressControllerClassMap: IngressControllerClassMap[],
|
ingressControllerClassMap: IngressControllerClassMapRowData[],
|
||||||
namespace?: string
|
namespace?: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { data: controllerMaps } = await axios.put<
|
const { data: controllerMaps } = await axios.put<
|
||||||
IngressControllerClassMap[]
|
IngressControllerClassMapRowData[]
|
||||||
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
|
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
|
||||||
return controllerMaps;
|
return controllerMaps;
|
||||||
} catch (e) {
|
} catch (e) {
|
|
@ -4,11 +4,17 @@ export type SupportedIngControllerTypes =
|
||||||
| 'other'
|
| 'other'
|
||||||
| 'custom';
|
| '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;
|
Name: string;
|
||||||
ClassName: string;
|
ClassName: string;
|
||||||
Type: SupportedIngControllerTypes;
|
Type: string;
|
||||||
Availability: boolean;
|
Availability: boolean;
|
||||||
New: boolean;
|
New: boolean;
|
||||||
Used: boolean; // if the controller is used by any ingress in the cluster
|
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;
|
||||||
|
}
|
8
app/react/kubernetes/queries/useGetMetricsMutation.ts
Normal file
8
app/react/kubernetes/queries/useGetMetricsMutation.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
|
||||||
|
import { getMetricsForAllNodes } from '../services/service';
|
||||||
|
|
||||||
|
// use this as a mutation because the metrics request should be manually fired when the user clicks to turn the metrics toggle on
|
||||||
|
export function useGetMetricsMutation() {
|
||||||
|
return useMutation(getMetricsForAllNodes);
|
||||||
|
}
|
|
@ -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';
|
import { EnvironmentId } from '../types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const environmentQueryKeys = {
|
||||||
base: () => ['environments'] as const,
|
base: () => ['environments'] as const,
|
||||||
item: (id: EnvironmentId) => [...queryKeys.base(), id] as const,
|
item: (id: EnvironmentId) => [...environmentQueryKeys.base(), id] as const,
|
||||||
registries: (environmentId: EnvironmentId) =>
|
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 { getAgentVersions } from '../environment.service';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { environmentQueryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useAgentVersionsList() {
|
export function useAgentVersionsList() {
|
||||||
return useQuery([...queryKeys.base(), 'agentVersions'], () =>
|
return useQuery([...environmentQueryKeys.base(), 'agentVersions'], () =>
|
||||||
getAgentVersions()
|
getAgentVersions()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,14 +7,14 @@ import {
|
||||||
} from '@/react/portainer/environments/types';
|
} from '@/react/portainer/environments/types';
|
||||||
import { withError } from '@/react-tools/react-query';
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { environmentQueryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useEnvironment<T = Environment | null>(
|
export function useEnvironment<T = Environment | null>(
|
||||||
id?: EnvironmentId,
|
id?: EnvironmentId,
|
||||||
select?: (environment: Environment | null) => T
|
select?: (environment: Environment | null) => T
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
id ? queryKeys.item(id) : [],
|
id ? environmentQueryKeys.item(id) : [],
|
||||||
() => (id ? getEndpoint(id) : null),
|
() => (id ? getEndpoint(id) : null),
|
||||||
{
|
{
|
||||||
select,
|
select,
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
getEnvironments,
|
getEnvironments,
|
||||||
} from '../environment.service';
|
} from '../environment.service';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { environmentQueryKeys } from './query-keys';
|
||||||
|
|
||||||
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ export function useEnvironmentList(
|
||||||
) {
|
) {
|
||||||
const { isLoading, data } = useQuery(
|
const { isLoading, data } = useQuery(
|
||||||
[
|
[
|
||||||
...queryKeys.base(),
|
...environmentQueryKeys.base(),
|
||||||
{
|
{
|
||||||
page,
|
page,
|
||||||
pageLimit,
|
pageLimit,
|
||||||
|
|
|
@ -5,14 +5,14 @@ import { EnvironmentId } from '../types';
|
||||||
import { Registry } from '../../registries/types/registry';
|
import { Registry } from '../../registries/types/registry';
|
||||||
import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries';
|
import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { environmentQueryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useEnvironmentRegistries<T = Array<Registry>>(
|
export function useEnvironmentRegistries<T = Array<Registry>>(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
queryOptions: { select?(data: Array<Registry>): T; enabled?: boolean } = {}
|
queryOptions: { select?(data: Array<Registry>): T; enabled?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
return useGenericRegistriesQuery(
|
return useGenericRegistriesQuery(
|
||||||
queryKeys.registries(environmentId),
|
environmentQueryKeys.registries(environmentId),
|
||||||
() => getEnvironmentRegistries(environmentId),
|
() => getEnvironmentRegistries(environmentId),
|
||||||
queryOptions
|
queryOptions
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,6 +5,9 @@ import {
|
||||||
EnvironmentId,
|
EnvironmentId,
|
||||||
EnvironmentStatusMessage,
|
EnvironmentStatusMessage,
|
||||||
Environment,
|
Environment,
|
||||||
|
KubernetesSettings,
|
||||||
|
DeploymentOptions,
|
||||||
|
EndpointChangeWindow,
|
||||||
} from '@/react/portainer/environments/types';
|
} from '@/react/portainer/environments/types';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { TagId } from '@/portainer/tags/types';
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
@ -12,17 +15,17 @@ import { TagId } from '@/portainer/tags/types';
|
||||||
import { EnvironmentGroupId } from '../environment-groups/types';
|
import { EnvironmentGroupId } from '../environment-groups/types';
|
||||||
import { buildUrl } from '../environment.service/utils';
|
import { buildUrl } from '../environment.service/utils';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { environmentQueryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useUpdateEnvironmentMutation() {
|
export function useUpdateEnvironmentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation(updateEnvironment, {
|
return useMutation(updateEnvironment, {
|
||||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
...withInvalidate(queryClient, [environmentQueryKeys.base()]),
|
||||||
...withError('Unable to update environment'),
|
...withError('Unable to update environment'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePayload {
|
export interface UpdateEnvironmentPayload extends Partial<Environment> {
|
||||||
TLSCACert?: File;
|
TLSCACert?: File;
|
||||||
TLSCert?: File;
|
TLSCert?: File;
|
||||||
TLSKey?: File;
|
TLSKey?: File;
|
||||||
|
@ -42,15 +45,18 @@ export interface UpdatePayload {
|
||||||
AzureAuthenticationKey: string;
|
AzureAuthenticationKey: string;
|
||||||
|
|
||||||
IsSetStatusMessage: boolean;
|
IsSetStatusMessage: boolean;
|
||||||
StatusMessage: Partial<EnvironmentStatusMessage>;
|
StatusMessage: EnvironmentStatusMessage;
|
||||||
|
Kubernetes?: KubernetesSettings;
|
||||||
|
DeploymentOptions?: DeploymentOptions | null;
|
||||||
|
ChangeWindow?: EndpointChangeWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateEnvironment({
|
export async function updateEnvironment({
|
||||||
id,
|
id,
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
id: EnvironmentId;
|
id: EnvironmentId;
|
||||||
payload: Partial<UpdatePayload>;
|
payload: Partial<UpdateEnvironmentPayload>;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
await uploadTLSFilesForEndpoint(
|
await uploadTLSFilesForEndpoint(
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { EnvironmentId } from '../types';
|
||||||
import { buildUrl } from '../environment.service/utils';
|
import { buildUrl } from '../environment.service/utils';
|
||||||
import { EnvironmentGroupId } from '../environment-groups/types';
|
import { EnvironmentGroupId } from '../environment-groups/types';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { environmentQueryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useUpdateEnvironmentsRelationsMutation() {
|
export function useUpdateEnvironmentsRelationsMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -25,7 +25,7 @@ export function useUpdateEnvironmentsRelationsMutation() {
|
||||||
updateEnvironmentRelations,
|
updateEnvironmentRelations,
|
||||||
mutationOptions(
|
mutationOptions(
|
||||||
withInvalidate(queryClient, [
|
withInvalidate(queryClient, [
|
||||||
queryKeys.base(),
|
environmentQueryKeys.base(),
|
||||||
edgeGroupQueryKeys.base(),
|
edgeGroupQueryKeys.base(),
|
||||||
groupQueryKeys.base(),
|
groupQueryKeys.base(),
|
||||||
tagKeys.all,
|
tagKeys.all,
|
||||||
|
|
|
@ -127,7 +127,7 @@ export type DeploymentOptions = {
|
||||||
/**
|
/**
|
||||||
* EndpointChangeWindow determine when GitOps stack/app updates may occur
|
* EndpointChangeWindow determine when GitOps stack/app updates may occur
|
||||||
*/
|
*/
|
||||||
interface EndpointChangeWindow {
|
export interface EndpointChangeWindow {
|
||||||
Enabled: boolean;
|
Enabled: boolean;
|
||||||
StartTime: string;
|
StartTime: string;
|
||||||
EndTime: string;
|
EndTime: string;
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useRouter } from '@uirouter/react';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Wand2 } from 'lucide-react';
|
import { Wand2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
|
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||||
|
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Environment,
|
Environment,
|
||||||
EnvironmentId,
|
EnvironmentId,
|
||||||
} from '@/react/portainer/environments/types';
|
} from '@/react/portainer/environments/types';
|
||||||
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
|
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||||
|
|
||||||
import { Stepper } from '@@/Stepper';
|
import { Stepper } from '@@/Stepper';
|
||||||
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
|
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Wand2, Plug2 } from 'lucide-react';
|
import { Wand2, Plug2 } from 'lucide-react';
|
||||||
|
|
||||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
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 DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
|
||||||
import Kube from '@/assets/ico/kube.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 { Terminal } from 'lucide-react';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
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 { Button } from '@@/buttons';
|
||||||
import { Icon } from '@@/Icon';
|
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 { HubspotForm } from '@@/HubspotForm';
|
||||||
import { Modal } from '@@/modals/Modal';
|
import { Modal } from '@@/modals/Modal';
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { ArrowUpCircle } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import clsx from 'clsx';
|
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 { useNodesCount } from '@/react/portainer/system/useNodesCount';
|
||||||
import {
|
import {
|
||||||
ContainerPlatform,
|
ContainerPlatform,
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { object, SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
|
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
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 { Button, LoadingButton } from '@@/buttons';
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue