diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js deleted file mode 100644 index 8b62c6af0..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js +++ /dev/null @@ -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} 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; - } -} diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.html b/app/kubernetes/components/helm/helm-templates/helm-templates.html deleted file mode 100644 index 4b998cf76..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.html +++ /dev/null @@ -1,113 +0,0 @@ -
- -
- -
-
-
- -
-
- {{ $ctrl.state.chart.name }} - - - Helm - -
-
-
-
-
-
-
- -
-
-
-
- -
-
-
- - - - - -
-
- -
- -
-
- - - - - You can get more information about Helm values file format in the - official documentation. - - - -
-
- -
- - -
Actions
-
-
- -
-
- -
-
- -
- - -
-
- - -
-
- diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.js b/app/kubernetes/components/helm/helm-templates/helm-templates.js deleted file mode 100644 index 9cd4b1158..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.js +++ /dev/null @@ -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: '<', - }, -}); diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 041fc35d2..27aa04444 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -58,8 +58,7 @@ import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/co import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection'; import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema'; import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable'; -import { HelmTemplatesList } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesList'; -import { HelmTemplatesListItem } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem'; +import { HelmTemplates } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplates'; import { namespacesModule } from './namespaces'; import { clusterManagementModule } from './clusterManagement'; @@ -209,17 +208,12 @@ export const ngModule = angular ]) ) .component( - 'helmTemplatesList', - r2a(withUIRouter(withCurrentUser(HelmTemplatesList)), [ - 'loading', - 'titleText', - 'charts', - 'selectAction', + 'helmTemplatesView', + r2a(withUIRouter(withCurrentUser(HelmTemplates)), [ + 'onSelectHelmChart', + 'namespace', + 'name', ]) - ) - .component( - 'helmTemplatesListItem', - r2a(HelmTemplatesListItem, ['model', 'onSelect', 'actions']) ); export const componentsModule = ngModule.name; diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index 35701b76c..5eda2d3a7 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -187,13 +187,7 @@
Selected Helm chart
- +
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 79549084a..89f416ac3 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -16,7 +16,8 @@ import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; class KubernetesDeployController { /* @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.$state = $state; this.$window = $window; @@ -110,6 +111,9 @@ class KubernetesDeployController { onSelectHelmChart(chart) { this.state.selectedHelmChart = chart; + + // Force a digest cycle to ensure the change is reflected in the UI + this.$scope.$apply(); } onChangeTemplateVariables(value) { diff --git a/app/react/components/modals/confirm.ts b/app/react/components/modals/confirm.ts index 4ca4a7e91..c7f3ba677 100644 --- a/app/react/components/modals/confirm.ts +++ b/app/react/components/modals/confirm.ts @@ -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) { return confirmDestructive({ title: 'Are you sure?', diff --git a/app/react/hooks/useCanExit.ts b/app/react/hooks/useCanExit.ts new file mode 100644 index 000000000..b7366be23 --- /dev/null +++ b/app/react/hooks/useCanExit.ts @@ -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 +) { + const stateName = useParentView().context.name; + useTransitionHook('onBefore', { exiting: stateName }, canExitCallback); +} diff --git a/app/kubernetes/components/helm/helm-templates/HelmIcon.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmIcon.tsx similarity index 100% rename from app/kubernetes/components/helm/helm-templates/HelmIcon.tsx rename to app/react/kubernetes/helm/HelmTemplates/HelmIcon.tsx diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx new file mode 100644 index 000000000..c8def2077 --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx @@ -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(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 ( +
+
+ {selectedChart ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx index e08e50cad..3c080d077 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx @@ -6,14 +6,16 @@ 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 { HelmTemplatesList } from './HelmTemplatesList'; -import { Chart } from './HelmTemplatesListItem'; // Sample test data const mockCharts: Chart[] = [ { name: 'test-chart-1', description: 'Test Chart 1 Description', + repo: 'https://example.com', annotations: { category: 'database', }, @@ -21,6 +23,7 @@ const mockCharts: Chart[] = [ { name: 'test-chart-2', description: 'Test Chart 2 Description', + repo: 'https://example.com', annotations: { category: 'database', }, @@ -28,6 +31,7 @@ const mockCharts: Chart[] = [ { name: 'nginx-chart', description: 'Nginx Web Server', + repo: 'https://example.com', annotations: { category: 'web', }, @@ -38,7 +42,6 @@ const selectActionMock = vi.fn(); function renderComponent({ loading = false, - titleText = 'Test Helm Templates', charts = mockCharts, selectAction = selectActionMock, } = {}) { @@ -48,7 +51,6 @@ function renderComponent({ withTestRouter(() => ( @@ -68,7 +70,7 @@ describe('HelmTemplatesList', () => { renderComponent(); // Check for the title - expect(screen.getByText('Test Helm Templates')).toBeInTheDocument(); + expect(screen.getByText('Helm chart')).toBeInTheDocument(); // Check for charts expect(screen.getByText('test-chart-1')).toBeInTheDocument(); diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx index 5cfdb6953..3d9034f4c 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx @@ -6,11 +6,12 @@ import { Link } from '@/react/components/Link'; import { InsightsBox } from '@@/InsightsBox'; import { SearchBar } from '@@/datatables/SearchBar'; -import { Chart, HelmTemplatesListItem } from './HelmTemplatesListItem'; +import { Chart } from '../types'; + +import { HelmTemplatesListItem } from './HelmTemplatesListItem'; interface Props { loading: boolean; - titleText: string; charts?: Chart[]; selectAction: (chart: Chart) => void; } @@ -70,7 +71,6 @@ function getFilteredCharts( export function HelmTemplatesList({ loading, - titleText, charts = [], selectAction, }: Props) { @@ -87,7 +87,7 @@ export function HelmTemplatesList({ return (
-
{titleText}
+
Helm chart
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx new file mode 100644 index 000000000..655b1d928 --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx @@ -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, 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(() => ( + + )), + user + ) + ); + + return { + ...render(), + 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(); + }); +}); diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx new file mode 100644 index 000000000..088c0c69a --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx @@ -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>(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 ( + <> + +
+
+
+ +
+
+ + + {selectedChart.name} + + + + + {' '} + Helm + + +
+
+ {selectedChart.description} +
+
+
+
+
+
+ +
+
+
+
+ handleSubmit(values)} + > + {({ values, setFieldValue }) => ( +
+
+ + {loadingValues && ( +
+ Loading values.yaml... +
+ )} + {!!initialValues && ( + 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{' '} + + official documentation + + . + + )} +
+
+ + + + )} +
+ + ); +} diff --git a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts new file mode 100644 index 000000000..e6664a317 --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts @@ -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)]), + } + ); +} diff --git a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts new file mode 100644 index 000000000..60791fe59 --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts @@ -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 { + try { + const response = await axios.get( + `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 { + try { + // Construct the URL with required repo parameter + const response = await axios.get('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 { + 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'), + }); +} diff --git a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartValues.ts b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartValues.ts new file mode 100644 index 000000000..29ec6e45a --- /dev/null +++ b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartValues.ts @@ -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(`/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'), + }); +} diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts new file mode 100644 index 000000000..8b043a080 --- /dev/null +++ b/app/react/kubernetes/helm/types.ts @@ -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; + apiVersion: string; + generated: string; +} + +export type InstallChartPayload = { + Name: string; + Repo: string; + Chart: string; + Values: string; + Namespace: string; +}; diff --git a/vitest.config.mts b/vitest.config.mts index eca3b62ed..7c343f6b7 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -24,6 +24,9 @@ export default defineConfig({ env: { 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()], });