diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 7913f7641..e4043985f 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -23,6 +23,7 @@ import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceV import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView'; import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView'; import { ClusterView } from '@/react/kubernetes/cluster/ClusterView'; +import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) @@ -79,6 +80,10 @@ export const viewsModule = angular [] ) ) + .component( + 'kubernetesHelmApplicationView', + r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), []) + ) .component( 'kubernetesClusterView', r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), []) diff --git a/app/kubernetes/views/applications/helm/helm.controller.js b/app/kubernetes/views/applications/helm/helm.controller.js deleted file mode 100644 index 0b027c2ef..000000000 --- a/app/kubernetes/views/applications/helm/helm.controller.js +++ /dev/null @@ -1,52 +0,0 @@ -import PortainerError from 'Portainer/error'; - -export default class KubernetesHelmApplicationController { - /* @ngInject */ - constructor($async, $state, Authentication, Notifications, HelmService) { - this.$async = $async; - this.$state = $state; - this.Authentication = Authentication; - this.Notifications = Notifications; - this.HelmService = HelmService; - } - - /** - * APPLICATION - */ - async getHelmApplication() { - try { - this.state.dataLoading = true; - const releases = await this.HelmService.listReleases(this.endpoint.Id, { filter: `^${this.state.params.name}$`, namespace: this.state.params.namespace }); - if (releases.length > 0) { - this.state.release = releases[0]; - } else { - throw new PortainerError(`Release ${this.state.params.name} not found`); - } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve helm application details'); - } finally { - this.state.dataLoading = false; - } - } - - $onInit() { - return this.$async(async () => { - this.state = { - dataLoading: true, - viewReady: false, - params: { - name: this.$state.params.name, - namespace: this.$state.params.namespace, - }, - release: { - name: undefined, - chart: undefined, - app_version: undefined, - }, - }; - - await this.getHelmApplication(); - this.state.viewReady = true; - }); - } -} diff --git a/app/kubernetes/views/applications/helm/helm.css b/app/kubernetes/views/applications/helm/helm.css deleted file mode 100644 index 784f878fd..000000000 --- a/app/kubernetes/views/applications/helm/helm.css +++ /dev/null @@ -1,5 +0,0 @@ -.release-table tr { - display: grid; - grid-auto-flow: column; - grid-template-columns: 1fr 4fr; -} diff --git a/app/kubernetes/views/applications/helm/helm.html b/app/kubernetes/views/applications/helm/helm.html deleted file mode 100644 index a815e8a9d..000000000 --- a/app/kubernetes/views/applications/helm/helm.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - -
-
-
- -
-
-
- -
- - Release -
-
- - - - - - - - - - - - - - - - -
Name - {{ $ctrl.state.release.name }} -
Chart - {{ $ctrl.state.release.chart }} -
App version - {{ $ctrl.state.release.app_version }} -
-
-
-
-
-
diff --git a/app/kubernetes/views/applications/helm/index.js b/app/kubernetes/views/applications/helm/index.js deleted file mode 100644 index b99f41d4b..000000000 --- a/app/kubernetes/views/applications/helm/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import angular from 'angular'; -import controller from './helm.controller'; -import './helm.css'; - -angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', { - templateUrl: './helm.html', - controller, - bindings: { - endpoint: '<', - }, -}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx new file mode 100644 index 000000000..296b17ff5 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@testing-library/react'; +import { HttpResponse } from 'msw'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { server, http } from '@/setup-tests/server'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { UserViewModel } from '@/portainer/models/user'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; + +import { HelmApplicationView } from './HelmApplicationView'; + +// Mock the necessary hooks and dependencies +const mockUseCurrentStateAndParams = vi.fn(); +const mockUseEnvironmentId = vi.fn(); + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: () => mockUseCurrentStateAndParams(), +})); + +vi.mock('@/react/hooks/useEnvironmentId', () => ({ + useEnvironmentId: () => mockUseEnvironmentId(), +})); + +function renderComponent() { + const user = new UserViewModel({ Username: 'user' }); + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(HelmApplicationView), user) + ); + return render(); +} + +describe('HelmApplicationView', () => { + beforeEach(() => { + // Set up default mock values + mockUseEnvironmentId.mockReturnValue(3); + mockUseCurrentStateAndParams.mockReturnValue({ + params: { + name: 'test-release', + namespace: 'default', + }, + }); + + // Set up default mock API responses + server.use( + http.get('/api/endpoints/3/kubernetes/helm', () => + HttpResponse.json([ + { + name: 'test-release', + chart: 'test-chart-1.0.0', + app_version: '1.0.0', + }, + ]) + ) + ); + }); + + it('should display helm release details when data is loaded', async () => { + renderComponent(); + + // Check for the page header + expect(await screen.findByText('Helm details')).toBeInTheDocument(); + + // Check for the release details + expect(screen.getByText('Release')).toBeInTheDocument(); + + // Check for the table content + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Chart')).toBeInTheDocument(); + expect(screen.getByText('App version')).toBeInTheDocument(); + + // Check for the actual values + expect(screen.getByTestId('k8sAppDetail-appName')).toHaveTextContent( + 'test-release' + ); + expect(screen.getByText('test-chart-1.0.0')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + }); + + it('should display error message when API request fails', async () => { + // Mock API failure + server.use( + http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error()) + ); + + // Mock console.error to prevent test output pollution + vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderComponent(); + + // Wait for the error message to appear + expect( + await screen.findByText('Failed to load Helm application details') + ).toBeInTheDocument(); + + // Restore console.error + vi.spyOn(console, 'error').mockRestore(); + }); + + it('should display error message when release is not found', async () => { + // Mock empty response (no releases found) + server.use( + http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([])) + ); + + // Mock console.error to prevent test output pollution + vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderComponent(); + + // Wait for the error message to appear + expect( + await screen.findByText('Failed to load Helm application details') + ).toBeInTheDocument(); + + // Restore console.error + vi.spyOn(console, 'error').mockRestore(); + }); +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx new file mode 100644 index 000000000..f50b59154 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx @@ -0,0 +1,79 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { PageHeader } from '@/react/components/PageHeader'; +import { Widget, WidgetBody, WidgetTitle } from '@/react/components/Widget'; +import helm from '@/assets/ico/vendor/helm.svg?c'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { ViewLoading } from '@@/ViewLoading'; +import { Alert } from '@@/Alert'; + +import { useHelmRelease } from './queries/useHelmRelease'; + +export function HelmApplicationView() { + const { params } = useCurrentStateAndParams(); + const environmentId = useEnvironmentId(); + + const name = params.name as string; + const namespace = params.namespace as string; + + const { + data: release, + isLoading, + error, + } = useHelmRelease(environmentId, name, namespace); + + if (isLoading) { + return ; + } + + if (error || !release) { + return ( + + ); + } + + return ( + <> + + +
+
+ + + + + + + + + + + + + + + + + + +
Name + {release.name} +
Chart{release.chart}
App version{release.app_version}
+
+
+
+
+ + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/index.ts b/app/react/kubernetes/helm/HelmApplicationView/index.ts new file mode 100644 index 000000000..e3adc8d92 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/index.ts @@ -0,0 +1 @@ +export { HelmApplicationView } from './HelmApplicationView'; diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts new file mode 100644 index 000000000..b7cfcb91c --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts @@ -0,0 +1,83 @@ +import { useQuery } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withGlobalError } from '@/react-tools/react-query'; +import PortainerError from 'Portainer/error'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +interface HelmRelease { + name: string; + chart: string; + app_version: string; +} +/** + * List all helm releases based on passed in options + * @param environmentId - Environment ID + * @param options - Options for filtering releases + * @returns List of helm releases + */ +export async function listReleases( + environmentId: EnvironmentId, + options: { + namespace?: string; + filter?: string; + selector?: string; + output?: string; + } = {} +): Promise { + try { + const { namespace, filter, selector, output } = options; + const url = `endpoints/${environmentId}/kubernetes/helm`; + const { data } = await axios.get(url, { + params: { namespace, filter, selector, output }, + }); + return data; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve release list'); + } +} + +/** + * React hook to fetch a specific Helm release + */ +export function useHelmRelease( + environmentId: EnvironmentId, + name: string, + namespace: string +) { + return useQuery( + [environmentId, 'helm', namespace, name], + () => getHelmRelease(environmentId, name, namespace), + { + enabled: !!environmentId, + ...withGlobalError('Unable to retrieve helm application details'), + } + ); +} + +/** + * Get a specific Helm release + */ +async function getHelmRelease( + environmentId: EnvironmentId, + name: string, + namespace: string +): Promise { + try { + const releases = await listReleases(environmentId, { + filter: `^${name}$`, + namespace, + }); + + if (releases.length > 0) { + return releases[0]; + } + + throw new PortainerError(`Release ${name} not found`); + } catch (err) { + throw new PortainerError( + 'Unable to retrieve helm application details', + err as Error + ); + } +}