mirror of
https://github.com/portainer/portainer.git
synced 2025-07-18 21:09:40 +02:00
chore(kubernetes): Migrate Helm Templates View to React R8S-239 (#587)
This commit is contained in:
parent
ad89df4d0d
commit
264ff5457b
20 changed files with 635 additions and 372 deletions
|
@ -1,207 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
|
||||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
|
||||||
import { HelmIcon } from './HelmIcon';
|
|
||||||
export default class HelmTemplatesController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications) {
|
|
||||||
this.$analytics = $analytics;
|
|
||||||
this.$async = $async;
|
|
||||||
this.$window = $window;
|
|
||||||
this.$state = $state;
|
|
||||||
this.$anchorScroll = $anchorScroll;
|
|
||||||
this.Authentication = Authentication;
|
|
||||||
this.HelmService = HelmService;
|
|
||||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
|
||||||
this.Notifications = Notifications;
|
|
||||||
|
|
||||||
this.fallbackIcon = HelmIcon;
|
|
||||||
|
|
||||||
this.editorUpdate = this.editorUpdate.bind(this);
|
|
||||||
this.uiCanExit = this.uiCanExit.bind(this);
|
|
||||||
this.installHelmchart = this.installHelmchart.bind(this);
|
|
||||||
this.getHelmValues = this.getHelmValues.bind(this);
|
|
||||||
this.selectHelmChart = this.selectHelmChart.bind(this);
|
|
||||||
this.getHelmRepoURLs = this.getHelmRepoURLs.bind(this);
|
|
||||||
this.getLatestCharts = this.getLatestCharts.bind(this);
|
|
||||||
this.getResourcePools = this.getResourcePools.bind(this);
|
|
||||||
this.clearHelmChart = this.clearHelmChart.bind(this);
|
|
||||||
|
|
||||||
$window.onbeforeunload = () => {
|
|
||||||
if (this.state.isEditorDirty) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHelmChart() {
|
|
||||||
this.state.chart = null;
|
|
||||||
this.onSelectHelmChart('');
|
|
||||||
}
|
|
||||||
|
|
||||||
editorUpdate(contentvalues) {
|
|
||||||
if (this.state.originalvalues === contentvalues) {
|
|
||||||
this.state.isEditorDirty = false;
|
|
||||||
} else {
|
|
||||||
this.state.values = contentvalues;
|
|
||||||
this.state.isEditorDirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async uiCanExit() {
|
|
||||||
if (this.state.isEditorDirty) {
|
|
||||||
return confirmWebEditorDiscard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async installHelmchart() {
|
|
||||||
this.state.actionInProgress = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
Name: this.name,
|
|
||||||
Repo: this.state.chart.repo,
|
|
||||||
Chart: this.state.chart.name,
|
|
||||||
Values: this.state.values,
|
|
||||||
Namespace: this.namespace,
|
|
||||||
};
|
|
||||||
await this.HelmService.install(this.endpoint.Id, payload);
|
|
||||||
this.Notifications.success('Success', 'Helm chart successfully installed');
|
|
||||||
this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.chart.name } });
|
|
||||||
this.state.isEditorDirty = false;
|
|
||||||
this.$state.go('kubernetes.applications');
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Installation error', err);
|
|
||||||
} finally {
|
|
||||||
this.state.actionInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getHelmValues() {
|
|
||||||
this.state.loadingValues = true;
|
|
||||||
try {
|
|
||||||
const { values } = await this.HelmService.values(this.state.chart.repo, this.state.chart.name);
|
|
||||||
this.state.values = values;
|
|
||||||
this.state.originalvalues = values;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm chart values.');
|
|
||||||
} finally {
|
|
||||||
this.state.loadingValues = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectHelmChart(chart) {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
this.state.showCustomValues = false;
|
|
||||||
this.state.chart = chart;
|
|
||||||
this.onSelectHelmChart(chart.name);
|
|
||||||
await this.getHelmValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description This function is used to get the helm repo urls for the endpoint and user
|
|
||||||
* @returns {Promise<string[]>} list of helm repo urls
|
|
||||||
*/
|
|
||||||
async getHelmRepoURLs() {
|
|
||||||
this.state.reposLoading = true;
|
|
||||||
try {
|
|
||||||
// fetch globally set helm repo and user helm repos (parallel)
|
|
||||||
const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.user.ID);
|
|
||||||
this.state.globalRepository = GlobalRepository;
|
|
||||||
const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
|
|
||||||
const uniqueHelmRepos = [...new Set([GlobalRepository, ...userHelmReposUrls])].map((url) => url.toLowerCase()).filter((url) => url); // remove duplicates and blank, to lowercase
|
|
||||||
this.state.repos = uniqueHelmRepos;
|
|
||||||
return uniqueHelmRepos;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo urls.');
|
|
||||||
} finally {
|
|
||||||
this.state.reposLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description This function is used to fetch the respective index.yaml files for the provided helm repo urls
|
|
||||||
* @param {string[]} helmRepos list of helm repositories
|
|
||||||
* @param {bool} append append charts returned from repo to existing list of helm charts
|
|
||||||
*/
|
|
||||||
async getLatestCharts(helmRepos) {
|
|
||||||
this.state.chartsLoading = true;
|
|
||||||
try {
|
|
||||||
const promiseList = helmRepos.map((repo) => this.HelmService.search(repo));
|
|
||||||
// fetch helm charts from all the provided helm repositories (parallel)
|
|
||||||
// Promise.allSettled is used to account for promise failure(s) - in cases the user has provided invalid helm repo
|
|
||||||
const chartPromises = await Promise.allSettled(promiseList);
|
|
||||||
const latestCharts = chartPromises
|
|
||||||
.filter((tp) => tp.status === 'fulfilled') // remove failed promises
|
|
||||||
.map((tp) => ({ entries: tp.value.entries, repo: helmRepos[chartPromises.indexOf(tp)] })) // extract chart entries with respective repo data
|
|
||||||
.flatMap(
|
|
||||||
({ entries, repo }) => Object.values(entries).map((charts) => ({ ...charts[0], repo })) // flatten chart entries to single array with respective repo
|
|
||||||
);
|
|
||||||
|
|
||||||
this.state.charts = latestCharts;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo charts.');
|
|
||||||
} finally {
|
|
||||||
this.state.chartsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getResourcePools() {
|
|
||||||
this.state.resourcePoolsLoading = true;
|
|
||||||
try {
|
|
||||||
const resourcePools = await this.KubernetesResourcePoolService.get();
|
|
||||||
|
|
||||||
const nonSystemNamespaces = resourcePools.filter(
|
|
||||||
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
|
|
||||||
);
|
|
||||||
this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
|
|
||||||
this.state.resourcePool = this.state.resourcePools[0];
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve initial helm data.');
|
|
||||||
} finally {
|
|
||||||
this.state.resourcePoolsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.user = this.Authentication.getUserDetails();
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
appName: '',
|
|
||||||
chart: null,
|
|
||||||
showCustomValues: false,
|
|
||||||
actionInProgress: false,
|
|
||||||
resourcePools: [],
|
|
||||||
resourcePool: '',
|
|
||||||
values: null,
|
|
||||||
originalvalues: null,
|
|
||||||
repos: [],
|
|
||||||
charts: [],
|
|
||||||
loadingValues: false,
|
|
||||||
isEditorDirty: false,
|
|
||||||
chartsLoading: false,
|
|
||||||
resourcePoolsLoading: false,
|
|
||||||
viewReady: false,
|
|
||||||
isAdmin: this.Authentication.isAdmin(),
|
|
||||||
globalRepository: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const helmRepos = await this.getHelmRepoURLs();
|
|
||||||
if (helmRepos) {
|
|
||||||
await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]);
|
|
||||||
}
|
|
||||||
if (this.state.charts.length > 0 && this.$state.params.chartName) {
|
|
||||||
const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName);
|
|
||||||
if (chart) {
|
|
||||||
this.selectHelmChart(chart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.viewReady = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$onDestroy() {
|
|
||||||
this.state.isEditorDirty = false;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,113 +0,0 @@
|
||||||
<div class="row">
|
|
||||||
<!-- helmchart-form -->
|
|
||||||
<div class="col-sm-12 p-0" ng-if="$ctrl.state.chart">
|
|
||||||
<rd-widget>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
|
|
||||||
<div class="vertical-center p-5">
|
|
||||||
<fallback-image src="$ctrl.state.chart.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'h-16 w-16'" size="'lg'"></fallback-image>
|
|
||||||
<div class="font-medium ml-4">
|
|
||||||
<div class="toolBarTitle text-[24px] mb-2">
|
|
||||||
{{ $ctrl.state.chart.name }}
|
|
||||||
<span class="space-left text-[14px] vertical-center font-normal">
|
|
||||||
<pr-icon icon="'svg-helm'" mode="'primary'"></pr-icon>
|
|
||||||
Helm
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted text-xs" ng-bind-html="$ctrl.state.chart.description"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="basis-1/4">
|
|
||||||
<div class="h-full w-full vertical-center justify-end pr-5">
|
|
||||||
<button type="button" class="btn btn-sm btn-link !text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white" ng-click="$ctrl.clearHelmChart()">
|
|
||||||
Clear selection
|
|
||||||
<pr-icon icon="'x'" class="ml-1"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rd-widget>
|
|
||||||
|
|
||||||
<form class="form-horizontal" name="$ctrl.helmTemplateCreationForm">
|
|
||||||
<div class="form-group mt-4">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
ng-if="!$ctrl.state.showCustomValues && !$ctrl.state.loadingValues"
|
|
||||||
class="btn btn-xs btn-default vertical-center !ml-0 mr-2"
|
|
||||||
ng-click="$ctrl.state.showCustomValues = true;"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'plus'" class="vertical-center"></pr-icon>
|
|
||||||
Show custom values
|
|
||||||
</button>
|
|
||||||
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues" role="status">
|
|
||||||
<inline-loader children="'Loading values.yaml...'" />
|
|
||||||
</span>
|
|
||||||
<button ng-if="$ctrl.state.showCustomValues" class="btn btn-xs btn-default vertical-center !ml-0 mr-2" ng-click="$ctrl.state.showCustomValues = false;">
|
|
||||||
<pr-icon icon="'minus'" class="vertical-center"></pr-icon>
|
|
||||||
Hide custom values
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- values override -->
|
|
||||||
<div ng-if="$ctrl.state.showCustomValues">
|
|
||||||
<!-- web-editor -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<web-editor-form
|
|
||||||
identifier="helm-app-creation-editor"
|
|
||||||
value="$ctrl.state.values"
|
|
||||||
on-change="($ctrl.editorUpdate)"
|
|
||||||
yml="true"
|
|
||||||
placeholder="Define or paste the content of your values yaml file here"
|
|
||||||
>
|
|
||||||
<editor-description class="vertical-center">
|
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
|
||||||
<span>
|
|
||||||
You can get more information about Helm values file format in the
|
|
||||||
<a href="https://helm.sh/docs/chart_template_guide/values_files/" target="_blank" class="hyperlink">official documentation</a>.
|
|
||||||
</span>
|
|
||||||
</editor-description>
|
|
||||||
</web-editor-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !web-editor -->
|
|
||||||
</div>
|
|
||||||
<!-- !values override -->
|
|
||||||
<!-- helm actions -->
|
|
||||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm !ml-0"
|
|
||||||
ng-disabled="!$ctrl.state.resourcePool || $ctrl.state.loadingValues || $ctrl.state.actionInProgress || !$ctrl.name"
|
|
||||||
ng-click="$ctrl.installHelmchart()"
|
|
||||||
button-spinner="$ctrl.state.actionInProgress"
|
|
||||||
data-cy="helm-install"
|
|
||||||
>
|
|
||||||
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
|
|
||||||
<span ng-hide="!$ctrl.state.actionInProgress">Installing Helm chart</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !helm actions -->
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<!-- helmchart-form -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Helm Charts Component -->
|
|
||||||
<div class="row" ng-if="!$ctrl.state.chart">
|
|
||||||
<div class="col-sm-12 p-0">
|
|
||||||
<helm-templates-list
|
|
||||||
title-text="'Helm chart'"
|
|
||||||
charts="$ctrl.state.charts"
|
|
||||||
table-key="$ctrl.state.charts"
|
|
||||||
select-action="$ctrl.selectHelmChart"
|
|
||||||
loading="$ctrl.state.chartsLoading || $ctrl.state.resourcePoolsLoading"
|
|
||||||
>
|
|
||||||
</helm-templates-list>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !Helm Charts Component -->
|
|
|
@ -1,14 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
import controller from './helm-templates.controller';
|
|
||||||
|
|
||||||
angular.module('portainer.kubernetes').component('helmTemplatesView', {
|
|
||||||
templateUrl: './helm-templates.html',
|
|
||||||
controller,
|
|
||||||
bindings: {
|
|
||||||
endpoint: '<',
|
|
||||||
namespace: '<',
|
|
||||||
stackName: '<',
|
|
||||||
onSelectHelmChart: '<',
|
|
||||||
name: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -58,8 +58,7 @@ import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/co
|
||||||
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
|
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
|
||||||
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
|
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
|
||||||
import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable';
|
import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable';
|
||||||
import { HelmTemplatesList } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesList';
|
import { HelmTemplates } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplates';
|
||||||
import { HelmTemplatesListItem } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem';
|
|
||||||
|
|
||||||
import { namespacesModule } from './namespaces';
|
import { namespacesModule } from './namespaces';
|
||||||
import { clusterManagementModule } from './clusterManagement';
|
import { clusterManagementModule } from './clusterManagement';
|
||||||
|
@ -209,17 +208,12 @@ export const ngModule = angular
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
'helmTemplatesList',
|
'helmTemplatesView',
|
||||||
r2a(withUIRouter(withCurrentUser(HelmTemplatesList)), [
|
r2a(withUIRouter(withCurrentUser(HelmTemplates)), [
|
||||||
'loading',
|
'onSelectHelmChart',
|
||||||
'titleText',
|
'namespace',
|
||||||
'charts',
|
'name',
|
||||||
'selectAction',
|
|
||||||
])
|
])
|
||||||
)
|
|
||||||
.component(
|
|
||||||
'helmTemplatesListItem',
|
|
||||||
r2a(HelmTemplatesListItem, ['model', 'onSelect', 'actions'])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const componentsModule = ngModule.name;
|
export const componentsModule = ngModule.name;
|
||||||
|
|
|
@ -187,13 +187,7 @@
|
||||||
<!-- Helm -->
|
<!-- Helm -->
|
||||||
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
|
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
|
||||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.selectedHelmChart">Selected Helm chart</div>
|
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.selectedHelmChart">Selected Helm chart</div>
|
||||||
<helm-templates-view
|
<helm-templates-view on-select-helm-chart="(ctrl.onSelectHelmChart)" namespace="ctrl.formValues.Namespace" name="ctrl.formValues.Name" />
|
||||||
on-select-helm-chart="(ctrl.onSelectHelmChart)"
|
|
||||||
endpoint="ctrl.endpoint"
|
|
||||||
namespace="ctrl.formValues.Namespace"
|
|
||||||
stack-name="ctrl.formValues.StackName"
|
|
||||||
name="ctrl.formValues.Name"
|
|
||||||
></helm-templates-view>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- !Helm -->
|
<!-- !Helm -->
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,8 @@ import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||||
|
|
||||||
class KubernetesDeployController {
|
class KubernetesDeployController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
|
constructor($scope, $async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
|
||||||
|
this.$scope = $scope;
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
this.$state = $state;
|
this.$state = $state;
|
||||||
this.$window = $window;
|
this.$window = $window;
|
||||||
|
@ -110,6 +111,9 @@ class KubernetesDeployController {
|
||||||
|
|
||||||
onSelectHelmChart(chart) {
|
onSelectHelmChart(chart) {
|
||||||
this.state.selectedHelmChart = chart;
|
this.state.selectedHelmChart = chart;
|
||||||
|
|
||||||
|
// Force a digest cycle to ensure the change is reflected in the UI
|
||||||
|
this.$scope.$apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeTemplateVariables(value) {
|
onChangeTemplateVariables(value) {
|
||||||
|
|
|
@ -47,6 +47,16 @@ export function confirmWebEditorDiscard() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmGenericDiscard() {
|
||||||
|
return openConfirm({
|
||||||
|
modalType: ModalType.Warn,
|
||||||
|
title: 'Are you sure?',
|
||||||
|
message:
|
||||||
|
'You currently have unsaved changes. Are you sure you want to leave?',
|
||||||
|
confirmButton: buildConfirmButton('Yes', 'danger'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function confirmDelete(message: ReactNode) {
|
export function confirmDelete(message: ReactNode) {
|
||||||
return confirmDestructive({
|
return confirmDestructive({
|
||||||
title: 'Are you sure?',
|
title: 'Are you sure?',
|
||||||
|
|
17
app/react/hooks/useCanExit.ts
Normal file
17
app/react/hooks/useCanExit.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Copied from https://github.com/ui-router/react/blob/master/src/hooks/useCanExit.ts
|
||||||
|
* TODO: Use package version of this hook when it becomes available: https://github.com/ui-router/react/pull/1227
|
||||||
|
*/
|
||||||
|
import { useParentView, useTransitionHook } from '@uirouter/react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook that can stop the router from exiting the state the hook is used in.
|
||||||
|
* If the callback returns true/undefined (or a Promise that resolves to true/undefined), the Transition will be allowed to continue.
|
||||||
|
* If the callback returns false (or a Promise that resolves to false), the Transition will be cancelled.
|
||||||
|
*/
|
||||||
|
export function useCanExit(
|
||||||
|
canExitCallback: () => boolean | undefined | Promise<boolean | undefined>
|
||||||
|
) {
|
||||||
|
const stateName = useParentView().context.name;
|
||||||
|
useTransitionHook('onBefore', { exiting: stateName }, canExitCallback);
|
||||||
|
}
|
55
app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
Normal file
55
app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { Chart } from '../types';
|
||||||
|
|
||||||
|
import { useHelmChartList } from './queries/useHelmChartList';
|
||||||
|
import { HelmTemplatesList } from './HelmTemplatesList';
|
||||||
|
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelectHelmChart: (chartName: string) => void;
|
||||||
|
namespace?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
||||||
|
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
|
||||||
|
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { data: charts = [], isLoading: chartsLoading } = useHelmChartList(
|
||||||
|
user.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
function clearHelmChart() {
|
||||||
|
setSelectedChart(null);
|
||||||
|
onSelectHelmChart('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChartSelection(chart: Chart) {
|
||||||
|
setSelectedChart(chart);
|
||||||
|
onSelectHelmChart(chart.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12 p-0">
|
||||||
|
{selectedChart ? (
|
||||||
|
<HelmTemplatesSelectedItem
|
||||||
|
selectedChart={selectedChart}
|
||||||
|
clearHelmChart={clearHelmChart}
|
||||||
|
namespace={namespace}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HelmTemplatesList
|
||||||
|
charts={charts}
|
||||||
|
selectAction={handleChartSelection}
|
||||||
|
loading={chartsLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -6,14 +6,16 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
|
||||||
|
import { Chart } from '../types';
|
||||||
|
|
||||||
import { HelmTemplatesList } from './HelmTemplatesList';
|
import { HelmTemplatesList } from './HelmTemplatesList';
|
||||||
import { Chart } from './HelmTemplatesListItem';
|
|
||||||
|
|
||||||
// Sample test data
|
// Sample test data
|
||||||
const mockCharts: Chart[] = [
|
const mockCharts: Chart[] = [
|
||||||
{
|
{
|
||||||
name: 'test-chart-1',
|
name: 'test-chart-1',
|
||||||
description: 'Test Chart 1 Description',
|
description: 'Test Chart 1 Description',
|
||||||
|
repo: 'https://example.com',
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'database',
|
category: 'database',
|
||||||
},
|
},
|
||||||
|
@ -21,6 +23,7 @@ const mockCharts: Chart[] = [
|
||||||
{
|
{
|
||||||
name: 'test-chart-2',
|
name: 'test-chart-2',
|
||||||
description: 'Test Chart 2 Description',
|
description: 'Test Chart 2 Description',
|
||||||
|
repo: 'https://example.com',
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'database',
|
category: 'database',
|
||||||
},
|
},
|
||||||
|
@ -28,6 +31,7 @@ const mockCharts: Chart[] = [
|
||||||
{
|
{
|
||||||
name: 'nginx-chart',
|
name: 'nginx-chart',
|
||||||
description: 'Nginx Web Server',
|
description: 'Nginx Web Server',
|
||||||
|
repo: 'https://example.com',
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'web',
|
category: 'web',
|
||||||
},
|
},
|
||||||
|
@ -38,7 +42,6 @@ const selectActionMock = vi.fn();
|
||||||
|
|
||||||
function renderComponent({
|
function renderComponent({
|
||||||
loading = false,
|
loading = false,
|
||||||
titleText = 'Test Helm Templates',
|
|
||||||
charts = mockCharts,
|
charts = mockCharts,
|
||||||
selectAction = selectActionMock,
|
selectAction = selectActionMock,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
@ -48,7 +51,6 @@ function renderComponent({
|
||||||
withTestRouter(() => (
|
withTestRouter(() => (
|
||||||
<HelmTemplatesList
|
<HelmTemplatesList
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleText={titleText}
|
|
||||||
charts={charts}
|
charts={charts}
|
||||||
selectAction={selectAction}
|
selectAction={selectAction}
|
||||||
/>
|
/>
|
||||||
|
@ -68,7 +70,7 @@ describe('HelmTemplatesList', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
// Check for the title
|
// Check for the title
|
||||||
expect(screen.getByText('Test Helm Templates')).toBeInTheDocument();
|
expect(screen.getByText('Helm chart')).toBeInTheDocument();
|
||||||
|
|
||||||
// Check for charts
|
// Check for charts
|
||||||
expect(screen.getByText('test-chart-1')).toBeInTheDocument();
|
expect(screen.getByText('test-chart-1')).toBeInTheDocument();
|
||||||
|
|
|
@ -6,11 +6,12 @@ import { Link } from '@/react/components/Link';
|
||||||
import { InsightsBox } from '@@/InsightsBox';
|
import { InsightsBox } from '@@/InsightsBox';
|
||||||
import { SearchBar } from '@@/datatables/SearchBar';
|
import { SearchBar } from '@@/datatables/SearchBar';
|
||||||
|
|
||||||
import { Chart, HelmTemplatesListItem } from './HelmTemplatesListItem';
|
import { Chart } from '../types';
|
||||||
|
|
||||||
|
import { HelmTemplatesListItem } from './HelmTemplatesListItem';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
titleText: string;
|
|
||||||
charts?: Chart[];
|
charts?: Chart[];
|
||||||
selectAction: (chart: Chart) => void;
|
selectAction: (chart: Chart) => void;
|
||||||
}
|
}
|
||||||
|
@ -70,7 +71,6 @@ function getFilteredCharts(
|
||||||
|
|
||||||
export function HelmTemplatesList({
|
export function HelmTemplatesList({
|
||||||
loading,
|
loading,
|
||||||
titleText,
|
|
||||||
charts = [],
|
charts = [],
|
||||||
selectAction,
|
selectAction,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
@ -87,7 +87,7 @@ export function HelmTemplatesList({
|
||||||
return (
|
return (
|
||||||
<section className="datatable" aria-label="Helm charts">
|
<section className="datatable" aria-label="Helm charts">
|
||||||
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
|
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
|
||||||
<div className="toolBarTitle vertical-center">{titleText}</div>
|
<div className="toolBarTitle vertical-center">Helm chart</div>
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={textFilter}
|
value={textFilter}
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { HelmIcon } from '@/kubernetes/components/helm/helm-templates/HelmIcon';
|
|
||||||
import { FallbackImage } from '@/react/components/FallbackImage';
|
import { FallbackImage } from '@/react/components/FallbackImage';
|
||||||
|
|
||||||
import Svg from '@@/Svg';
|
import Svg from '@@/Svg';
|
||||||
|
|
||||||
export interface Chart {
|
import { Chart } from '../types';
|
||||||
name: string;
|
|
||||||
description: string;
|
import { HelmIcon } from './HelmIcon';
|
||||||
icon?: string;
|
|
||||||
annotations?: {
|
|
||||||
category?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HelmTemplatesListItemProps {
|
interface HelmTemplatesListItemProps {
|
||||||
model: Chart;
|
model: Chart;
|
||||||
|
@ -30,7 +24,7 @@ export function HelmTemplatesListItem(props: HelmTemplatesListItemProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="blocklist-item mx-0 bg-inherit text-start"
|
className="blocklist-item !mx-0 bg-inherit text-start"
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MutationOptions } from '@tanstack/react-query';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
|
||||||
|
import { Chart } from '../types';
|
||||||
|
|
||||||
|
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
||||||
|
|
||||||
|
const mockMutate = vi.fn();
|
||||||
|
const mockNotifySuccess = vi.fn();
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/portainer/services/notifications', () => ({
|
||||||
|
notifySuccess: (title: string, text: string) =>
|
||||||
|
mockNotifySuccess(title, text),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./queries/useHelmChartValues', () => ({
|
||||||
|
useHelmChartValues: vi.fn().mockReturnValue({
|
||||||
|
data: { values: 'test-values' },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./queries/useHelmChartInstall', () => ({
|
||||||
|
useHelmChartInstall: vi.fn().mockReturnValue({
|
||||||
|
mutate: (params: Record<string, string>, options?: MutationOptions) =>
|
||||||
|
mockMutate(params, options),
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useAnalytics', () => ({
|
||||||
|
useAnalytics: vi.fn().mockReturnValue({
|
||||||
|
trackEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sample test data
|
||||||
|
const mockChart: Chart = {
|
||||||
|
name: 'test-chart',
|
||||||
|
description: 'Test Chart Description',
|
||||||
|
repo: 'https://example.com',
|
||||||
|
icon: 'test-icon-url',
|
||||||
|
annotations: {
|
||||||
|
category: 'database',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHelmChartMock = vi.fn();
|
||||||
|
const mockRouterStateService = {
|
||||||
|
go: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderComponent({
|
||||||
|
selectedChart = mockChart,
|
||||||
|
clearHelmChart = clearHelmChartMock,
|
||||||
|
namespace = 'test-namespace',
|
||||||
|
name = 'test-name',
|
||||||
|
} = {}) {
|
||||||
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withUserProvider(
|
||||||
|
withTestRouter(() => (
|
||||||
|
<HelmTemplatesSelectedItem
|
||||||
|
selectedChart={selectedChart}
|
||||||
|
clearHelmChart={clearHelmChart}
|
||||||
|
namespace={namespace}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...render(<Wrapped />),
|
||||||
|
user,
|
||||||
|
mockRouterStateService,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelmTemplatesSelectedItem', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display selected chart information', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Check for chart details
|
||||||
|
expect(screen.getByText('test-chart')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Chart Description')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Clear selection')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Helm')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle custom values editor', async () => {
|
||||||
|
renderComponent();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// First show the editor
|
||||||
|
await user.click(await screen.findByText('Custom values'));
|
||||||
|
|
||||||
|
// Verify editor is visible
|
||||||
|
expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Now hide the editor
|
||||||
|
await user.click(await screen.findByText('Custom values'));
|
||||||
|
|
||||||
|
// Editor should be hidden
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('helm-app-creation-editor')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should install helm chart and navigate when install button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Click install button
|
||||||
|
await user.click(screen.getByText('Install'));
|
||||||
|
|
||||||
|
// Check mutate was called with correct values
|
||||||
|
expect(mockMutate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
Name: 'test-name',
|
||||||
|
Repo: 'https://example.com',
|
||||||
|
Chart: 'test-chart',
|
||||||
|
Values: 'test-values',
|
||||||
|
Namespace: 'test-namespace',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ onSuccess: expect.any(Function) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable install button when namespace or name is undefined', () => {
|
||||||
|
renderComponent({ namespace: '' });
|
||||||
|
expect(screen.getByText('Install')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Form, Formik, FormikProps } from 'formik';
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||||
|
import { useCanExit } from '@/react/hooks/useCanExit';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { Button } from '@@/buttons/Button';
|
||||||
|
import { FallbackImage } from '@@/FallbackImage';
|
||||||
|
import Svg from '@@/Svg';
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
import { confirmGenericDiscard } from '@@/modals/confirm';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
|
||||||
|
import { Chart } from '../types';
|
||||||
|
|
||||||
|
import { useHelmChartValues } from './queries/useHelmChartValues';
|
||||||
|
import { HelmIcon } from './HelmIcon';
|
||||||
|
import { useHelmChartInstall } from './queries/useHelmChartInstall';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedChart: Chart;
|
||||||
|
clearHelmChart: () => void;
|
||||||
|
namespace?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
values: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyValues: FormValues = {
|
||||||
|
values: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HelmTemplatesSelectedItem({
|
||||||
|
selectedChart,
|
||||||
|
clearHelmChart,
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
|
const { mutate: installHelmChart, isLoading: isInstalling } =
|
||||||
|
useHelmChartInstall();
|
||||||
|
const { data: initialValues, isLoading: loadingValues } =
|
||||||
|
useHelmChartValues(selectedChart);
|
||||||
|
|
||||||
|
const formikRef = useRef<FormikProps<FormValues>>(null);
|
||||||
|
useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
if (!name || !namespace) {
|
||||||
|
// Theoretically this should never happen and is mainly to keep typescript happy
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installHelmChart(
|
||||||
|
{
|
||||||
|
Name: name,
|
||||||
|
Repo: selectedChart.repo,
|
||||||
|
Chart: selectedChart.name,
|
||||||
|
Values: values.values,
|
||||||
|
Namespace: namespace,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
analytics.trackEvent('kubernetes-helm-install', {
|
||||||
|
category: 'kubernetes',
|
||||||
|
metadata: {
|
||||||
|
'chart-name': selectedChart.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
notifySuccess('Success', 'Helm chart successfully installed');
|
||||||
|
|
||||||
|
// Reset the form so page can be navigated away from without getting "Are you sure?"
|
||||||
|
formikRef.current?.resetForm();
|
||||||
|
router.stateService.go('kubernetes.applications');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Widget>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
|
||||||
|
<div className="vertical-center p-5">
|
||||||
|
<FallbackImage
|
||||||
|
src={selectedChart.icon}
|
||||||
|
fallbackIcon={HelmIcon}
|
||||||
|
className="h-16 w-16"
|
||||||
|
/>
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
{selectedChart.name}
|
||||||
|
</span>
|
||||||
|
<span className="space-left pr-2 text-xs">
|
||||||
|
<span className="vertical-center">
|
||||||
|
<Svg icon="helm" className="icon icon-primary" />
|
||||||
|
</span>{' '}
|
||||||
|
<span>Helm</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted text-xs">
|
||||||
|
{selectedChart.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="basis-1/4">
|
||||||
|
<div className="h-full w-full vertical-center justify-end pr-5">
|
||||||
|
<Button
|
||||||
|
color="link"
|
||||||
|
className="!text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white"
|
||||||
|
onClick={clearHelmChart}
|
||||||
|
data-cy="clear-selection"
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
<Icon icon={X} className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
<Formik
|
||||||
|
innerRef={formikRef}
|
||||||
|
initialValues={initialValues ?? emptyValues}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
|
>
|
||||||
|
{({ values, setFieldValue }) => (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<div className="form-group !m-0">
|
||||||
|
<FormSection title="Custom values" isFoldable className="mt-4">
|
||||||
|
{loadingValues && (
|
||||||
|
<div className="col-sm-12 p-0">
|
||||||
|
<InlineLoader>Loading values.yaml...</InlineLoader>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!initialValues && (
|
||||||
|
<WebEditorForm
|
||||||
|
id="helm-app-creation-editor"
|
||||||
|
value={values.values}
|
||||||
|
onChange={(value) => setFieldValue('values', value)}
|
||||||
|
type="yaml"
|
||||||
|
data-cy="helm-app-creation-editor"
|
||||||
|
placeholder="Define or paste the content of your values yaml file here"
|
||||||
|
>
|
||||||
|
You can get more information about Helm values file format
|
||||||
|
in the{' '}
|
||||||
|
<a
|
||||||
|
href="https://helm.sh/docs/chart_template_guide/values_files/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</WebEditorForm>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
submitLabel="Install"
|
||||||
|
loadingText="Installing Helm chart"
|
||||||
|
isLoading={isInstalling}
|
||||||
|
isValid={!!namespace && !!name && !loadingValues}
|
||||||
|
data-cy="helm-install"
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import {
|
||||||
|
queryClient,
|
||||||
|
withGlobalError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys';
|
||||||
|
|
||||||
|
import { InstallChartPayload } from '../../types';
|
||||||
|
|
||||||
|
async function installHelmChart(
|
||||||
|
payload: InstallChartPayload,
|
||||||
|
environmentId: EnvironmentId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`endpoints/${environmentId}/kubernetes/helm`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err as Error, 'Installation error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHelmChartInstall() {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
return useMutation(
|
||||||
|
(values: InstallChartPayload) => installHelmChart(values, environmentId),
|
||||||
|
{
|
||||||
|
...withGlobalError('Unable to install Helm chart'),
|
||||||
|
...withInvalidate(queryClient, [queryKeys.applications(environmentId)]),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
HelmChartsResponse,
|
||||||
|
HelmRepositoriesResponse,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
async function getHelmRepositories(userId: number): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<HelmRepositoriesResponse>(
|
||||||
|
`users/${userId}/helm/repositories`
|
||||||
|
);
|
||||||
|
const { GlobalRepository, UserRepositories } = response.data;
|
||||||
|
|
||||||
|
// Extract URLs from user repositories
|
||||||
|
const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
|
||||||
|
|
||||||
|
// Combine global and user repositories, remove duplicates and empty values
|
||||||
|
const uniqueHelmRepos = [
|
||||||
|
...new Set([GlobalRepository, ...userHelmReposUrls]),
|
||||||
|
]
|
||||||
|
.map((url) => url.toLowerCase())
|
||||||
|
.filter((url) => url);
|
||||||
|
|
||||||
|
return uniqueHelmRepos;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Failed to fetch Helm repositories');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChartsFromRepo(repo: string): Promise<Chart[]> {
|
||||||
|
try {
|
||||||
|
// Construct the URL with required repo parameter
|
||||||
|
const response = await axios.get<HelmChartsResponse>('templates/helm', {
|
||||||
|
params: { repo },
|
||||||
|
});
|
||||||
|
|
||||||
|
return compact(
|
||||||
|
Object.values(response.data.entries).map((versions) =>
|
||||||
|
versions[0] ? { ...versions[0], repo } : null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors from chart repositories as some may error but others may not
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCharts(userId: number): Promise<Chart[]> {
|
||||||
|
try {
|
||||||
|
// First, get all the helm repositories
|
||||||
|
const repos = await getHelmRepositories(userId);
|
||||||
|
|
||||||
|
// Then fetch charts from each repository in parallel
|
||||||
|
const chartsPromises = repos.map((repo) => getChartsFromRepo(repo));
|
||||||
|
const chartsArrays = await Promise.all(chartsPromises);
|
||||||
|
|
||||||
|
// Flatten the arrays of charts into a single array
|
||||||
|
return chartsArrays.flat();
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Failed to fetch Helm charts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to fetch helm charts from all accessible repositories
|
||||||
|
* @param userId User ID
|
||||||
|
*/
|
||||||
|
export function useHelmChartList(userId: number) {
|
||||||
|
return useQuery([userId, 'helm-charts'], () => getCharts(userId), {
|
||||||
|
enabled: !!userId,
|
||||||
|
...withGlobalError('Unable to retrieve Helm charts'),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { Chart } from '../../types';
|
||||||
|
|
||||||
|
async function getHelmChartValues(chart: string, repo: string) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<string>(`/templates/helm/values`, {
|
||||||
|
params: {
|
||||||
|
repo,
|
||||||
|
chart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err as Error, 'Unable to get Helm chart values');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHelmChartValues(chart: Chart) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['helm-chart-values', chart.repo, chart.name],
|
||||||
|
queryFn: () => getHelmChartValues(chart.name, chart.repo),
|
||||||
|
enabled: !!chart.name,
|
||||||
|
select: (data) => ({
|
||||||
|
values: data,
|
||||||
|
}),
|
||||||
|
...withGlobalError('Unable to get Helm chart values'),
|
||||||
|
});
|
||||||
|
}
|
37
app/react/kubernetes/helm/types.ts
Normal file
37
app/react/kubernetes/helm/types.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
export interface Chart extends HelmChartResponse {
|
||||||
|
repo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmChartResponse {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon?: string;
|
||||||
|
annotations?: {
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmRepositoryResponse {
|
||||||
|
Id: number;
|
||||||
|
UserId: number;
|
||||||
|
URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmRepositoriesResponse {
|
||||||
|
GlobalRepository: string;
|
||||||
|
UserRepositories: HelmRepositoryResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmChartsResponse {
|
||||||
|
entries: Record<string, HelmChartResponse[]>;
|
||||||
|
apiVersion: string;
|
||||||
|
generated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstallChartPayload = {
|
||||||
|
Name: string;
|
||||||
|
Repo: string;
|
||||||
|
Chart: string;
|
||||||
|
Values: string;
|
||||||
|
Namespace: string;
|
||||||
|
};
|
|
@ -24,6 +24,9 @@ export default defineConfig({
|
||||||
env: {
|
env: {
|
||||||
PORTAINER_EDITION: 'CE',
|
PORTAINER_EDITION: 'CE',
|
||||||
},
|
},
|
||||||
|
deps: {
|
||||||
|
inline: [/@radix-ui/, /codemirror-json-schema/], // https://github.com/radix-ui/primitives/issues/2974#issuecomment-2186808459
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [svgr({ include: /\?c$/ }), tsconfigPaths()],
|
plugins: [svgr({ include: /\?c$/ }), tsconfigPaths()],
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue