-
+
{item.KubernetesApplications ? (
}
onChange={(values) => onChange(values)}
id={formId}
- placeholder="Define or paste the content of your manifest file here"
+ textTip="Define or paste the content of your manifest file here"
type="yaml"
/>
diff --git a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
index d2f705c5f..ffb92e7bd 100644
--- a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
@@ -1,5 +1,6 @@
import { Event } from 'kubernetes-types/core/v1';
import { History } from 'lucide-react';
+import { ReactNode } from 'react';
import { IndexOptional } from '@/react/kubernetes/configs/types';
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
@@ -16,6 +17,8 @@ type Props = {
isLoading: boolean;
'data-cy': string;
noWidget?: boolean;
+ title?: ReactNode;
+ titleIcon?: ReactNode;
};
export function EventsDatatable({
@@ -24,6 +27,8 @@ export function EventsDatatable({
isLoading,
'data-cy': dataCy,
noWidget,
+ title = 'Events',
+ titleIcon = History,
}: Props) {
return (
>
@@ -31,8 +36,8 @@ export function EventsDatatable({
columns={columns}
settingsManager={tableState}
isLoading={isLoading}
- title="Events"
- titleIcon={History}
+ title={title}
+ titleIcon={titleIcon}
getRowId={(row) => row.metadata?.uid || ''}
disableSelect
renderTableSettings={() => (
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
index 47b15a544..0d381d682 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
@@ -1,4 +1,8 @@
+import { Row } from '@tanstack/react-table';
+import { Event } from 'kubernetes-types/core/v1';
+
import { Badge, BadgeType } from '@@/Badge';
+import { filterHOC } from '@@/datatables/Filter';
import { columnHelper } from './helper';
@@ -7,6 +11,14 @@ export const eventType = columnHelper.accessor('type', {
cell: ({ getValue }) => (
{getValue()}
),
+
+ meta: {
+ filter: filterHOC('Filter by event type'),
+ },
+ enableColumnFilter: true,
+ filterFn: (row: Row, _: string, filterValue: string[]) =>
+ filterValue.length === 0 ||
+ (!!row.original.type && filterValue.includes(row.original.type)),
});
function getBadgeColor(status?: string): BadgeType {
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/index.ts b/app/react/kubernetes/components/EventsDatatable/columns/index.ts
index 05ae483d5..f49658090 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/index.ts
+++ b/app/react/kubernetes/components/EventsDatatable/columns/index.ts
@@ -2,5 +2,6 @@ import { date } from './date';
import { kind } from './kind';
import { eventType } from './eventType';
import { message } from './message';
+import { name } from './name';
-export const columns = [date, kind, eventType, message];
+export const columns = [date, name, kind, eventType, message];
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
index 856d6bf8b..641662291 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
@@ -1,8 +1,21 @@
+import { Row } from '@tanstack/react-table';
+import { Event } from 'kubernetes-types/core/v1';
+
+import { filterHOC } from '@@/datatables/Filter';
+
import { columnHelper } from './helper';
export const kind = columnHelper.accessor(
(event) => event.involvedObject.kind,
{
header: 'Kind',
+ meta: {
+ filter: filterHOC('Filter by kind'),
+ },
+ enableColumnFilter: true,
+ filterFn: (row: Row, _: string, filterValue: string[]) =>
+ filterValue.length === 0 ||
+ (!!row.original.involvedObject.kind &&
+ filterValue.includes(row.original.involvedObject.kind)),
}
);
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/name.tsx b/app/react/kubernetes/components/EventsDatatable/columns/name.tsx
new file mode 100644
index 000000000..26bc0c8db
--- /dev/null
+++ b/app/react/kubernetes/components/EventsDatatable/columns/name.tsx
@@ -0,0 +1,16 @@
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor(
+ (event) => event.involvedObject.name ?? '-',
+ {
+ header: 'Name',
+ cell: ({ getValue }) => {
+ const name = getValue();
+ return (
+
+ {name}
+
+ );
+ },
+ }
+);
diff --git a/app/react/kubernetes/components/YAMLInspector.tsx b/app/react/kubernetes/components/YAMLInspector.tsx
index 5baadf3a7..9e678e9f5 100644
--- a/app/react/kubernetes/components/YAMLInspector.tsx
+++ b/app/react/kubernetes/components/YAMLInspector.tsx
@@ -29,7 +29,7 @@ export function YAMLInspector({
void;
+};
export function ChartActions({
environmentId,
releaseName,
namespace,
- currentRevision,
-}: {
- environmentId: EnvironmentId;
- releaseName: string;
- namespace?: string;
- currentRevision?: number;
-}) {
- const hasPreviousRevision = currentRevision && currentRevision >= 2;
+ latestRevision,
+ earlistRevision,
+ selectedRevision,
+ release,
+ updateRelease,
+}: Props) {
+ const showRollbackButton =
+ latestRevision && earlistRevision && latestRevision > earlistRevision;
return (
-
+
+
- {hasPreviousRevision && (
+ {showRollbackButton && (
({
function renderButton(props = {}) {
const defaultProps = {
latestRevision: 3, // So we're rolling back to revision 2
+ selectedRevision: 3, // This simulates the selectedRevision from URL params
environmentId: 1,
releaseName: 'test-release',
namespace: 'default',
...props,
};
- const Wrapped = withTestQueryProvider(RollbackButton);
+ const Wrapped = withTestQueryProvider(
+ withTestRouter(RollbackButton, {
+ route: '/?revision=3',
+ })
+ );
return render( );
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
index 3d870d3a8..ea8a79bac 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
@@ -1,4 +1,5 @@
import { RotateCcw } from 'lucide-react';
+import { useRouter } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
@@ -12,6 +13,7 @@ import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
type Props = {
latestRevision: number;
+ selectedRevision?: number;
environmentId: EnvironmentId;
releaseName: string;
namespace?: string;
@@ -19,13 +21,16 @@ type Props = {
export function RollbackButton({
latestRevision,
+ selectedRevision,
environmentId,
releaseName,
namespace,
}: Props) {
- // the selectedRevision can be a prop when selecting a revision is implemented
- const selectedRevision = latestRevision ? latestRevision - 1 : undefined;
-
+ // when the latest revision is selected, rollback to the previous revision
+ // otherwise, rollback to the selected revision
+ const rollbackRevision =
+ selectedRevision === latestRevision ? latestRevision - 1 : selectedRevision;
+ const router = useRouter();
const rollbackMutation = useHelmRollbackMutation(environmentId);
return (
@@ -38,7 +43,7 @@ export function RollbackButton({
color="default"
size="medium"
>
- Rollback to #{selectedRevision}
+ Rollback to #{rollbackRevision}
);
@@ -47,7 +52,7 @@ export function RollbackButton({
title: 'Are you sure?',
modalType: ModalType.Warn,
confirmButton: buildConfirmButton('Rollback'),
- message: `Rolling back will restore the application to revision #${selectedRevision}, which will cause service interruption. Do you wish to continue?`,
+ message: `Rolling back will restore the application to revision #${rollbackRevision}, which could cause service interruption. Do you wish to continue?`,
});
if (!confirmed) {
return;
@@ -56,14 +61,20 @@ export function RollbackButton({
rollbackMutation.mutate(
{
releaseName,
- params: { namespace, revision: selectedRevision },
+ params: { namespace, revision: rollbackRevision },
},
{
onSuccess: () => {
notifySuccess(
'Success',
- `Application rolled back to revision #${selectedRevision} successfully.`
+ `Application rolled back to revision #${rollbackRevision} successfully.`
);
+ // set the revision url param to undefined to refresh the page at the latest revision
+ router.stateService.go('kubernetes.helm', {
+ namespace,
+ name: releaseName,
+ revision: undefined,
+ });
},
}
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
new file mode 100644
index 000000000..4bea0bf47
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
@@ -0,0 +1,182 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi } from 'vitest';
+
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { withTestRouter } from '@/react/test-utils/withRouter';
+
+import {
+ useHelmRepoVersions,
+ ChartVersion,
+} from '../queries/useHelmRepositories';
+import { HelmRelease } from '../../types';
+
+import { openUpgradeHelmModal } from './UpgradeHelmModal';
+import { UpgradeButton } from './UpgradeButton';
+
+// Mock the upgrade modal function
+vi.mock('./UpgradeHelmModal', () => ({
+ openUpgradeHelmModal: vi.fn(() => Promise.resolve(undefined)),
+}));
+
+// Mock the notifications service
+vi.mock('@/portainer/services/notifications', () => ({
+ notifySuccess: vi.fn(),
+}));
+
+// Mock the useHelmRepoVersions and useHelmRepositories hooks
+vi.mock('../queries/useHelmRepositories', () => ({
+ useHelmRepoVersions: vi.fn(() => ({
+ data: [
+ { Version: '1.0.0', Repo: 'stable' },
+ { Version: '1.1.0', Repo: 'stable' },
+ ],
+ isInitialLoading: false,
+ isError: false,
+ })),
+ useHelmRepositories: vi.fn(() => ({
+ data: ['repo1', 'repo2'],
+ isInitialLoading: false,
+ isError: false,
+ })),
+}));
+
+function renderButton(props = {}) {
+ const defaultProps = {
+ environmentId: 1,
+ releaseName: 'test-release',
+ namespace: 'default',
+ release: {
+ name: 'test-release',
+ chart: {
+ metadata: {
+ name: 'test-chart',
+ version: '1.0.0',
+ },
+ },
+ values: {
+ userSuppliedValues: '{}',
+ },
+ manifest: '',
+ } as HelmRelease,
+ updateRelease: vi.fn(),
+ ...props,
+ };
+
+ const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton));
+ return render( );
+}
+
+describe('UpgradeButton', () => {
+ test('should display the upgrade button', () => {
+ renderButton();
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ test('should be disabled when no versions are available', () => {
+ const data: ChartVersion[] = [];
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data,
+ isInitialLoading: false,
+ isError: false,
+ });
+
+ renderButton();
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ expect(button).toBeDisabled();
+ });
+
+ test('should show loading state when checking for versions', () => {
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data: [],
+ isInitialLoading: true,
+ isError: false,
+ });
+
+ renderButton();
+
+ expect(
+ screen.getByText('Checking for new versions...')
+ ).toBeInTheDocument();
+ });
+
+ test('should show "No versions available" when no versions are found', () => {
+ const data: ChartVersion[] = [];
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data,
+ isInitialLoading: false,
+ isError: false,
+ });
+
+ renderButton();
+
+ expect(screen.getByText('No versions available')).toBeInTheDocument();
+ });
+
+ test('should open upgrade modal when clicked', async () => {
+ const user = userEvent.setup();
+ const mockRelease = {
+ name: 'test-release',
+ chart: {
+ metadata: {
+ name: 'test-chart',
+ version: '1.0.0',
+ },
+ },
+ values: {
+ userSuppliedValues: '{}',
+ },
+ manifest: '',
+ } as HelmRelease;
+
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data: [
+ { Version: '1.0.0', Repo: 'stable' },
+ { Version: '1.1.0', Repo: 'stable' },
+ ],
+ isInitialLoading: false,
+ isError: false,
+ });
+
+ renderButton({ release: mockRelease });
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(openUpgradeHelmModal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'test-release',
+ chart: 'test-chart',
+ namespace: 'default',
+ values: '{}',
+ version: '1.0.0',
+ }),
+ expect.arrayContaining([
+ { Version: '1.0.0', Repo: 'stable' },
+ { Version: '1.1.0', Repo: 'stable' },
+ ])
+ );
+ });
+ });
+
+ test('should not execute the upgrade if modal is cancelled', async () => {
+ const mockUpdateRelease = vi.fn();
+ vi.mocked(openUpgradeHelmModal).mockResolvedValueOnce(undefined);
+
+ const user = userEvent.setup();
+ renderButton({ updateRelease: mockUpdateRelease });
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(openUpgradeHelmModal).toHaveBeenCalled();
+ });
+
+ expect(mockUpdateRelease).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
new file mode 100644
index 000000000..4c385c388
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
@@ -0,0 +1,167 @@
+import { ArrowUp } from 'lucide-react';
+import { useRouter } from '@uirouter/react';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { notifySuccess } from '@/portainer/services/notifications';
+import { semverCompare } from '@/react/common/semver-utils';
+
+import { LoadingButton } from '@@/buttons';
+import { InlineLoader } from '@@/InlineLoader';
+import { Tooltip } from '@@/Tip/Tooltip';
+import { Link } from '@@/Link';
+
+import { HelmRelease } from '../../types';
+import {
+ useUpdateHelmReleaseMutation,
+ UpdateHelmReleasePayload,
+} from '../queries/useUpdateHelmReleaseMutation';
+import {
+ ChartVersion,
+ useHelmRepoVersions,
+ useHelmRepositories,
+} from '../queries/useHelmRepositories';
+import { useHelmRelease } from '../queries/useHelmRelease';
+
+import { openUpgradeHelmModal } from './UpgradeHelmModal';
+
+export function UpgradeButton({
+ environmentId,
+ releaseName,
+ namespace,
+ release,
+ updateRelease,
+}: {
+ environmentId: EnvironmentId;
+ releaseName: string;
+ namespace: string;
+ release?: HelmRelease;
+ updateRelease: (release: HelmRelease) => void;
+}) {
+ const router = useRouter();
+ const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
+
+ const repositoriesQuery = useHelmRepositories();
+ const helmRepoVersionsQuery = useHelmRepoVersions(
+ release?.chart.metadata?.name || '',
+ 60 * 60 * 1000, // 1 hour
+ repositoriesQuery.data
+ );
+ const versions = helmRepoVersionsQuery.data;
+
+ // Combined loading state
+ const isInitialLoading =
+ repositoriesQuery.isInitialLoading ||
+ helmRepoVersionsQuery.isInitialLoading;
+ const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
+
+ const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
+ select: (data) => data.chart.metadata?.version,
+ });
+ const latestVersionAvailable = versions[0]?.Version ?? '';
+ const isNewVersionAvailable =
+ latestVersion?.data &&
+ semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
+
+ const editableHelmRelease: UpdateHelmReleasePayload = {
+ name: releaseName,
+ namespace: namespace || '',
+ values: release?.values?.userSuppliedValues,
+ chart: release?.chart.metadata?.name || '',
+ version: release?.chart.metadata?.version,
+ };
+
+ return (
+
+ openUpgradeForm(versions, release)}
+ disabled={
+ versions.length === 0 ||
+ isInitialLoading ||
+ isError ||
+ release?.info?.status?.startsWith('pending')
+ }
+ loadingText="Upgrading..."
+ isLoading={updateHelmReleaseMutation.isLoading}
+ icon={ArrowUp}
+ size="medium"
+ >
+ Upgrade
+
+ {versions.length === 0 && isInitialLoading && (
+
+ Checking for new versions...
+
+ )}
+ {versions.length === 0 && !isInitialLoading && !isError && (
+
+ No versions available
+
+ Portainer is unable to find any versions for this chart in the
+ repositories saved. Try adding a new repository which contains
+ the chart in the{' '}
+
+ Helm repositories settings
+
+
+ }
+ />
+
+ )}
+ {isNewVersionAvailable && (
+
+ New version available ({latestVersionAvailable})
+
+ )}
+
+ );
+
+ async function openUpgradeForm(
+ versions: ChartVersion[],
+ release?: HelmRelease
+ ) {
+ const result = await openUpgradeHelmModal(editableHelmRelease, versions);
+
+ if (result) {
+ handleUpgrade(result, release);
+ }
+ }
+
+ function handleUpgrade(
+ payload: UpdateHelmReleasePayload,
+ release?: HelmRelease
+ ) {
+ if (release?.info) {
+ const updatedRelease = {
+ ...release,
+ info: {
+ ...release.info,
+ status: 'pending-upgrade',
+ description: 'Preparing upgrade',
+ },
+ };
+ updateRelease(updatedRelease);
+ }
+ updateHelmReleaseMutation.mutate(payload, {
+ onSuccess: () => {
+ notifySuccess('Success', 'Helm chart upgraded successfully');
+ // set the revision url param to undefined to refresh the page at the latest revision
+ router.stateService.go('kubernetes.helm', {
+ namespace,
+ name: releaseName,
+ revision: undefined,
+ });
+ },
+ });
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx
new file mode 100644
index 000000000..bb71f28c5
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx
@@ -0,0 +1,151 @@
+import { useState } from 'react';
+import { ArrowUp } from 'lucide-react';
+
+import { withReactQuery } from '@/react-tools/withReactQuery';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+
+import { Modal, OnSubmit, openModal } from '@@/modals';
+import { Button } from '@@/buttons';
+import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
+import { Input } from '@@/form-components/Input';
+import { CodeEditor } from '@@/CodeEditor';
+import { FormControl } from '@@/form-components/FormControl';
+import { WidgetTitle } from '@@/Widget';
+
+import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
+import { ChartVersion } from '../queries/useHelmRepositories';
+
+interface Props {
+ onSubmit: OnSubmit
;
+ values: UpdateHelmReleasePayload;
+ versions: ChartVersion[];
+}
+
+export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
+ const versionOptions: Option[] = versions.map((version) => {
+ const isCurrentVersion = version.Version === values.version;
+ const label = `${version.Repo}@${version.Version}${
+ isCurrentVersion ? ' (current)' : ''
+ }`;
+ return {
+ label,
+ value: version,
+ };
+ });
+ const defaultVersion =
+ versionOptions.find((v) => v.value.Version === values.version)?.value ||
+ versionOptions[0]?.value;
+ const [version, setVersion] = useState(defaultVersion);
+ const [userValues, setUserValues] = useState(values.values || '');
+
+ return (
+ onSubmit()}
+ size="lg"
+ className="flex flex-col h-[80vh] px-0"
+ aria-label="upgrade-helm"
+ >
+ }
+ />
+
+
+
+ onSubmit()}
+ color="secondary"
+ key="cancel-button"
+ size="medium"
+ data-cy="cancel-button-cy"
+ >
+ Cancel
+
+
+ onSubmit({
+ name: values.name,
+ values: userValues,
+ namespace: values.namespace,
+ chart: values.chart,
+ repo: version.Repo,
+ version: version.Version,
+ })
+ }
+ color="primary"
+ key="update-button"
+ size="medium"
+ data-cy="update-button-cy"
+ >
+ Upgrade
+
+
+
+
+ );
+}
+
+export async function openUpgradeHelmModal(
+ values: UpdateHelmReleasePayload,
+ versions: ChartVersion[]
+) {
+ return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
+ values,
+ versions,
+ });
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
index c0e6b8d71..89f522d3a 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
@@ -7,12 +7,14 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
+import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
import { HelmApplicationView } from './HelmApplicationView';
// Mock the necessary hooks and dependencies
const mockUseCurrentStateAndParams = vi.fn();
const mockUseEnvironmentId = vi.fn();
+mockLocalizeDate();
vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({
...(await importOriginal()),
@@ -32,32 +34,109 @@ const minimalHelmRelease = {
chart: {
metadata: {
name: 'test-chart',
- // appVersion: '1.0.0', // can be missing for a minimal release
version: '2.2.2',
},
},
info: {
status: 'deployed',
+ last_deployed: '2021-01-01T00:00:00Z',
// notes: 'This is a test note', // can be missing for a minimal release
},
manifest: 'This is a test manifest',
};
-const helmReleaseWithAdditionalDetails = {
- ...minimalHelmRelease,
- info: {
- ...minimalHelmRelease.info,
- notes: 'This is a test note',
- },
+// Create a more complete helm release object for testing
+const completeHelmRelease = {
+ name: 'test-release',
+ version: '1',
+ namespace: 'default',
chart: {
- ...minimalHelmRelease.chart,
metadata: {
- ...minimalHelmRelease.chart.metadata,
+ name: 'test-chart',
appVersion: '1.0.0',
+ version: '2.2.2',
},
},
+ info: {
+ status: 'deployed',
+ notes: 'This is a test note',
+ resources: [
+ {
+ kind: 'Deployment',
+ name: 'test-deployment',
+ namespace: 'default',
+ uid: 'test-deployment-uid',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Running',
+ message: 'All replicas are ready',
+ },
+ },
+ metadata: {
+ name: 'test-deployment',
+ namespace: 'default',
+ },
+ },
+ {
+ kind: 'Service',
+ name: 'test-service',
+ namespace: 'default',
+ uid: 'test-service-uid',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Available',
+ message: 'Service is available',
+ },
+ },
+ metadata: {
+ name: 'test-service',
+ namespace: 'default',
+ },
+ },
+ ],
+ },
+ manifest: 'This is a test manifest',
+ values: {
+ // Add some values to ensure the Values tab is present
+ replicaCount: 1,
+ image: {
+ repository: 'nginx',
+ tag: 'latest',
+ },
+ },
+ resources: [
+ {
+ kind: 'Deployment',
+ name: 'test-deployment',
+ namespace: 'default',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Running',
+ message: 'All replicas are ready',
+ },
+ },
+ metadata: {
+ name: 'test-deployment',
+ namespace: 'default',
+ },
+ },
+ ],
};
+const helmReleaseHistory = [
+ {
+ version: 1,
+ updated: '2023-06-01T12:00:00Z',
+ status: 'deployed',
+ chart: 'test-chart-1.0.0',
+ app_version: '1.0.0',
+ description: 'Install complete',
+ },
+];
+
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
@@ -66,92 +145,171 @@ function renderComponent() {
return render( );
}
-describe('HelmApplicationView', () => {
- beforeEach(() => {
- // Set up default mock values
- mockUseEnvironmentId.mockReturnValue(3);
- mockUseCurrentStateAndParams.mockReturnValue({
- params: {
- name: 'test-release',
- namespace: 'default',
- },
+describe(
+ 'HelmApplicationView',
+ () => {
+ beforeEach(() => {
+ // Set up default mock values
+ mockUseEnvironmentId.mockReturnValue(3);
+ mockUseCurrentStateAndParams.mockReturnValue({
+ params: {
+ name: 'test-release',
+ namespace: 'default',
+ },
+ });
});
- });
- it('should display helm release details for minimal release when data is loaded', async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
+ it('should display helm release details for minimal release when data is loaded', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
- server.use(
- http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
- HttpResponse.json(minimalHelmRelease)
- )
- );
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.json(minimalHelmRelease)
+ ),
+ http.get('/api/users/undefined/helm/repositories', () =>
+ HttpResponse.json({
+ GlobalRepository: 'https://charts.helm.sh/stable',
+ UserRepositories: [
+ { Id: '1', URL: 'https://charts.helm.sh/stable' },
+ ],
+ })
+ ),
+ http.get('/api/templates/helm', () =>
+ HttpResponse.json({
+ entries: {
+ 'test-chart': [{ version: '1.0.0' }],
+ },
+ })
+ ),
+ http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
+ HttpResponse.json(helmReleaseHistory)
+ ),
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () =>
+ HttpResponse.json({
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: { resourceVersion: '12345' },
+ items: [],
+ })
+ )
+ );
- const { findByText, findAllByText } = renderComponent();
+ const { findByText, findAllByText } = renderComponent();
- // Check for the page header
- expect(await findByText('Helm details')).toBeInTheDocument();
+ // Check for the page header
+ expect(await findByText('Helm details')).toBeInTheDocument();
- // Check for the badge content
- expect(await findByText(/Namespace/)).toBeInTheDocument();
- expect(await findByText(/Chart version:/)).toBeInTheDocument();
- expect(await findByText(/Chart:/)).toBeInTheDocument();
- expect(await findByText(/Revision/)).toBeInTheDocument();
+ // Check for the badge content
+ expect(await findByText(/Namespace: default/)).toBeInTheDocument();
+ expect(
+ await findByText(/Chart version: test-chart-2.2.2/)
+ ).toBeInTheDocument();
+ expect(await findByText(/Chart: test-chart/)).toBeInTheDocument();
+ expect(await findByText(/Revision: #1/)).toBeInTheDocument();
+ expect(
+ await findByText(/Last deployed: Jan 1, 2021, 12:00 AM/)
+ ).toBeInTheDocument();
+ // Check for the actual values
+ expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
+ expect(await findAllByText(/test-chart/)).toHaveLength(2); // title and badge (not checking revision list item)
- // Check for the actual values
- expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
- expect(await findAllByText(/test-chart/)).toHaveLength(2);
+ // There shouldn't be a notes tab when there are no notes
+ expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
- // There shouldn't be a notes tab when there are no notes
- expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
+ // There shouldn't be an app version badge when it's missing
+ expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
- // There shouldn't be an app version badge when it's missing
- expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
+ // Ensure there are no console errors
+ // eslint-disable-next-line no-console
+ expect(console.error).not.toHaveBeenCalled();
- // Ensure there are no console errors
- // eslint-disable-next-line no-console
- expect(console.error).not.toHaveBeenCalled();
+ // Restore console.error
+ vi.spyOn(console, 'error').mockRestore();
+ });
- // Restore console.error
- vi.spyOn(console, 'error').mockRestore();
- });
+ it('should display error message when API request fails', async () => {
+ // Mock API failure
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.error()
+ ),
+ // Add mock for events endpoint
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () =>
+ HttpResponse.json({
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: { resourceVersion: '12345' },
+ items: [],
+ })
+ )
+ );
- it('should display error message when API request fails', async () => {
- // Mock API failure
- server.use(
- http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
- HttpResponse.error()
- )
- );
+ // Mock console.error to prevent test output pollution
+ vi.spyOn(console, 'error').mockImplementation(() => {});
- // Mock console.error to prevent test output pollution
- vi.spyOn(console, 'error').mockImplementation(() => {});
+ renderComponent();
- renderComponent();
+ // Wait for the error message to appear
+ expect(
+ await screen.findByText(
+ 'Failed to load Helm application details',
+ {},
+ { timeout: 6500 }
+ )
+ ).toBeInTheDocument();
- // 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();
+ });
- // Restore console.error
- vi.spyOn(console, 'error').mockRestore();
- });
+ it('should display additional details when available in helm release', async () => {
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.json(completeHelmRelease)
+ ),
+ http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
+ HttpResponse.json(helmReleaseHistory)
+ ),
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () =>
+ HttpResponse.json({
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: { resourceVersion: '12345' },
+ items: [],
+ })
+ )
+ );
- it('should display additional details when available in helm release', async () => {
- server.use(
- http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
- HttpResponse.json(helmReleaseWithAdditionalDetails)
- )
- );
+ const { findByText } = renderComponent();
- const { findByText } = renderComponent();
+ expect(await findByText('Helm details')).toBeInTheDocument();
- // Check for the notes tab when notes are available
- expect(await findByText(/Notes/)).toBeInTheDocument();
+ // Check for the app version badge when it's available
+ await waitFor(() => {
+ expect(
+ screen.getByText(/App version/, { exact: false })
+ ).toBeInTheDocument();
+ });
- // Check for the app version badge when it's available
- expect(await findByText(/App version/)).toBeInTheDocument();
- expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument();
- });
-});
+ await waitFor(() => {
+ // Look for specific tab text
+ expect(screen.getByText('Resources')).toBeInTheDocument();
+ expect(screen.getByText('Values')).toBeInTheDocument();
+ expect(screen.getByText('Manifest')).toBeInTheDocument();
+ expect(screen.getByText('Notes')).toBeInTheDocument();
+ expect(screen.getByText('Events')).toBeInTheDocument();
+ });
+
+ expect(await findByText(/App version: 1.0.0/)).toBeInTheDocument();
+ });
+ },
+ {
+ timeout: 7000,
+ }
+);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
index 164943223..625bb55fe 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -1,4 +1,5 @@
import { useCurrentStateAndParams } from '@uirouter/react';
+import { useQueryClient } from '@tanstack/react-query';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { PageHeader } from '@/react/components/PageHeader';
@@ -15,14 +16,25 @@ import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
import { useHelmRelease } from './queries/useHelmRelease';
import { ChartActions } from './ChartActions/ChartActions';
+import { HelmRevisionList } from './HelmRevisionList';
+import { HelmRevisionListSheet } from './HelmRevisionListSheet';
+import { useHelmHistory } from './queries/useHelmHistory';
export function HelmApplicationView() {
const environmentId = useEnvironmentId();
+ const queryClient = useQueryClient();
const { params } = useCurrentStateAndParams();
- const { name, namespace } = params;
+ const { name, namespace, revision } = params;
+ const helmHistoryQuery = useHelmHistory(environmentId, name, namespace);
+ const latestRevision = helmHistoryQuery.data?.[0]?.version;
+ const earlistRevision =
+ helmHistoryQuery.data?.[helmHistoryQuery.data.length - 1]?.version;
+ // when loading the page fresh, the revision is undefined, so use the latest revision
+ const selectedRevision = revision ? parseInt(revision, 10) : latestRevision;
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
showResources: true,
+ revision: selectedRevision,
});
return (
@@ -38,26 +50,61 @@ export function HelmApplicationView() {
-
- {name && (
-
-
-
+
+
+ {name && (
+
+
+
+
+
+
+ {
+ queryClient.setQueryData(
+ [
+ environmentId,
+ 'helm',
+ 'releases',
+ namespace,
+ name,
+ true,
+ ],
+ updatedRelease
+ );
+ }}
+ />
+
+
+
+ )}
+
+
-
-
- )}
-
-
-
+
+
+
+
+
+
@@ -68,10 +115,16 @@ export function HelmApplicationView() {
type HelmDetailsProps = {
isLoading: boolean;
isError: boolean;
- release: HelmRelease | undefined;
+ selectedRevision?: number;
+ release?: HelmRelease;
};
-function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
+function HelmDetails({
+ isLoading,
+ isError,
+ release,
+ selectedRevision,
+}: HelmDetailsProps) {
if (isLoading) {
return ;
}
@@ -82,16 +135,16 @@ function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
);
}
- if (!data) {
+ if (!release || !selectedRevision) {
return ;
}
return (
<>
-
+
-
+
>
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx
new file mode 100644
index 000000000..638197ae0
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx
@@ -0,0 +1,116 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
+
+import { HelmRelease } from '../types';
+
+import { HelmRevisionItem } from './HelmRevisionItem';
+
+const mockHelmRelease: HelmRelease = {
+ name: 'my-release',
+ version: 1,
+ info: {
+ status: 'deployed',
+ last_deployed: '2024-01-01T00:00:00Z',
+ },
+ chart: {
+ metadata: {
+ name: 'my-app',
+ version: '1.0.0',
+ },
+ },
+ manifest: 'apiVersion: v1\nkind: Service\nmetadata:\n name: my-service',
+};
+
+mockLocalizeDate();
+
+vi.mock('@uirouter/react', () => ({
+ useCurrentStateAndParams: () => ({
+ params: {
+ namespace: 'default',
+ name: 'my-release',
+ },
+ }),
+}));
+
+const mockUseCurrentStateAndParams = vi.fn();
+
+vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({
+ ...(await importOriginal()),
+ useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
+}));
+
+function getTestComponent() {
+ return withTestRouter(HelmRevisionItem);
+}
+
+describe('HelmRevisionItem', () => {
+ it('should display correct revision details', () => {
+ const TestComponent = getTestComponent();
+ render(
+
+ );
+
+ // Check status badge
+ expect(screen.getByText('Deployed')).toBeInTheDocument();
+
+ // Check revision number
+ expect(screen.getByText('Revision #1')).toBeInTheDocument();
+
+ // Check chart name and version
+ expect(screen.getByText('my-app-1.0.0')).toBeInTheDocument();
+
+ // Check deployment date
+ expect(screen.getByText('Jan 1, 2024, 12:00 AM')).toBeInTheDocument();
+ });
+
+ it('should have selected class when currentRevision matches item version', () => {
+ const TestComponent = getTestComponent();
+ const { container } = render(
+
+ );
+
+ const blocklistItem = container.querySelector('.blocklist-item');
+ expect(blocklistItem).toHaveClass('blocklist-item--selected');
+ });
+
+ it('should not have selected class when currentRevision does not match item version', () => {
+ const TestComponent = getTestComponent();
+ const { container } = render(
+
+ );
+
+ const blocklistItem = container.querySelector('.blocklist-item');
+ expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
+ });
+
+ it('should not have selected class when currentRevision is undefined', () => {
+ const TestComponent = getTestComponent();
+ const { container } = render(
+
+ );
+
+ const blocklistItem = container.querySelector('.blocklist-item');
+ expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx
new file mode 100644
index 000000000..73a29107f
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx
@@ -0,0 +1,49 @@
+import { localizeDate } from '@/react/common/date-utils';
+
+import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
+import { Link } from '@@/Link';
+import { Badge } from '@@/Badge';
+
+import { HelmRelease } from '../types';
+import { getStatusColor, getStatusText } from '../helm-status-utils';
+
+export function HelmRevisionItem({
+ item,
+ currentRevision,
+ namespace,
+ name,
+}: {
+ item: HelmRelease;
+ currentRevision?: number;
+ namespace: string;
+ name: string;
+}) {
+ return (
+
+
+
+
+ {getStatusText(item.info?.status)}
+
+ Revision #{item.version}
+
+
+
+ {item.chart.metadata?.name}-{item.chart.metadata?.version}
+
+ {item.info?.last_deployed && (
+
+ {localizeDate(new Date(item.info.last_deployed))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx
new file mode 100644
index 000000000..07ed2a8b2
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx
@@ -0,0 +1,43 @@
+import { useCurrentStateAndParams } from '@uirouter/react';
+import { History } from 'lucide-react';
+
+import { WidgetIcon } from '@@/Widget/WidgetIcon';
+
+import { HelmRelease } from '../types';
+
+import { HelmRevisionItem } from './HelmRevisionItem';
+
+export function HelmRevisionList({
+ currentRevision,
+ history,
+}: {
+ currentRevision?: number;
+ history: HelmRelease[] | undefined;
+}) {
+ const { params } = useCurrentStateAndParams();
+ const { name, namespace } = params;
+
+ if (!history) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ Revisions
+
+ {history?.map((historyItem) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx
new file mode 100644
index 000000000..95016f080
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx
@@ -0,0 +1,40 @@
+import { Eye } from 'lucide-react';
+
+import { Icon } from '@@/Icon';
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTrigger,
+} from '@@/Sheet';
+
+import { HelmRelease } from '../types';
+
+import { HelmRevisionList } from './HelmRevisionList';
+
+export function HelmRevisionListSheet({
+ currentRevision,
+ history,
+}: {
+ currentRevision: number | undefined;
+ history: HelmRelease[] | undefined;
+}) {
+ return (
+
+
+
+ View revisions
+
+
+
+
+
+ View the history of this Helm application.
+
+
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
index 29f9e07d8..a7e32b247 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
@@ -1,24 +1,19 @@
import { Badge } from '@/react/components/Badge';
+import { localizeDate } from '@/react/common/date-utils';
import { Alert } from '@@/Alert';
import { HelmRelease } from '../types';
+import {
+ DeploymentStatus,
+ getStatusColor,
+ getStatusText,
+} from '../helm-status-utils';
interface Props {
release: HelmRelease;
}
-export enum DeploymentStatus {
- DEPLOYED = 'deployed',
- FAILED = 'failed',
- PENDING = 'pending-install',
- PENDINGUPGRADE = 'pending-upgrade',
- PENDINGROLLBACK = 'pending-rollback',
- SUPERSEDED = 'superseded',
- UNINSTALLED = 'uninstalled',
- UNINSTALLING = 'uninstalling',
-}
-
export function HelmSummary({ release }: Props) {
const isSuccess =
release.info?.status === DeploymentStatus.DEPLOYED ||
@@ -29,9 +24,14 @@ export function HelmSummary({ release }: Props) {
- {getText(release.info?.status)}
+ {getStatusText(release.info?.status)}
+ {!!release.info?.description && !isSuccess && (
+
+ {release.info?.description}
+
+ )}
{!!release.namespace && Namespace: {release.namespace} }
{!!release.version && Revision: #{release.version} }
@@ -47,12 +47,13 @@ export function HelmSummary({ release }: Props) {
{release.chart.metadata.version}
)}
+ {!!release.info?.last_deployed && (
+
+ Last deployed:{' '}
+ {localizeDate(new Date(release.info.last_deployed))}
+
+ )}
- {!!release.info?.description && !isSuccess && (
-
- {release.info?.description}
-
- )}
);
@@ -74,38 +75,3 @@ function getAlertColor(status?: string) {
return 'info';
}
}
-
-function getStatusColor(status?: string) {
- switch (status?.toLowerCase()) {
- case DeploymentStatus.DEPLOYED:
- return 'success';
- case DeploymentStatus.FAILED:
- return 'danger';
- case DeploymentStatus.PENDING:
- case DeploymentStatus.PENDINGUPGRADE:
- case DeploymentStatus.PENDINGROLLBACK:
- case DeploymentStatus.UNINSTALLING:
- return 'warn';
- case DeploymentStatus.SUPERSEDED:
- default:
- return 'info';
- }
-}
-
-function getText(status?: string) {
- switch (status?.toLowerCase()) {
- case DeploymentStatus.DEPLOYED:
- return 'Deployed';
- case DeploymentStatus.FAILED:
- return 'Failed';
- case DeploymentStatus.PENDING:
- case DeploymentStatus.PENDINGUPGRADE:
- case DeploymentStatus.PENDINGROLLBACK:
- case DeploymentStatus.UNINSTALLING:
- return 'Pending';
- case DeploymentStatus.SUPERSEDED:
- return 'Superseded';
- default:
- return 'Unknown';
- }
-}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx
new file mode 100644
index 000000000..109d98299
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx
@@ -0,0 +1,193 @@
+import { fireEvent, render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi } from 'vitest';
+
+import { DiffControl, DiffViewMode } from './DiffControl';
+
+// Create a mock for useDebounce that directly passes the setter function
+vi.mock('@/react/hooks/useDebounce', () => ({
+ useDebounce: (initialValue: number, setter: (value: number) => void) =>
+ // Return the initial value and a function that directly calls the setter
+ [initialValue, setter],
+}));
+
+function renderComponent({
+ selectedRevisionNumber = 5,
+ latestRevisionNumber = 10,
+ compareRevisionNumber = 4,
+ setCompareRevisionNumber = vi.fn(),
+ earliestRevisionNumber = 1,
+ diffViewMode = 'view' as DiffViewMode,
+ setDiffViewMode = vi.fn(),
+ isUserSupplied = false,
+ setIsUserSupplied = vi.fn(),
+ showUserSuppliedCheckbox = false,
+} = {}) {
+ return render(
+
+ );
+}
+
+describe('DiffControl', () => {
+ it('should only render the user supplied checkbox when latestRevisionNumber is 1 and showUserSuppliedCheckbox is true', () => {
+ const { queryByLabelText } = renderComponent({
+ latestRevisionNumber: 1,
+ showUserSuppliedCheckbox: true,
+ setIsUserSupplied: vi.fn(),
+ });
+ expect(queryByLabelText('View')).toBeNull();
+ expect(queryByLabelText('Diff with previous')).toBeNull();
+ expect(queryByLabelText('Diff with specific revision:')).toBeNull();
+ expect(queryByLabelText('User defined only')).toBeInTheDocument();
+ });
+
+ it('should not render any controls when latestRevisionNumber is 1 and showUserSuppliedCheckbox is false', () => {
+ const { queryByLabelText } = renderComponent({
+ latestRevisionNumber: 1,
+ showUserSuppliedCheckbox: false,
+ });
+ expect(queryByLabelText('Diff with previous')).toBeNull();
+ expect(queryByLabelText('Diff with specific revision:')).toBeNull();
+ expect(queryByLabelText('View')).toBeNull();
+ expect(queryByLabelText('User defined only')).toBeNull();
+ });
+
+ it('should render view option', () => {
+ const { getByLabelText } = renderComponent();
+ expect(getByLabelText('View')).toBeInTheDocument();
+ });
+
+ it('should render "Diff with previous" option when earliestRevisionNumber < selectedRevisionNumber', () => {
+ const { getByLabelText } = renderComponent({
+ earliestRevisionNumber: 3,
+ selectedRevisionNumber: 5,
+ });
+ expect(getByLabelText('Diff with previous')).toBeInTheDocument();
+ });
+
+ it('should render "Diff with previous" option as disabled when earliestRevisionNumber >= selectedRevisionNumber', () => {
+ const { getByLabelText } = renderComponent({
+ earliestRevisionNumber: 5,
+ selectedRevisionNumber: 5,
+ });
+
+ expect(getByLabelText('View')).toBeInTheDocument();
+ expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
+ // 'Diff with previous' should exist and be disabled
+ const diffWithPreviousOption = getByLabelText('Diff with previous');
+ expect(diffWithPreviousOption).toBeInTheDocument();
+ expect(diffWithPreviousOption).toBeDisabled();
+ });
+
+ it('should render "Diff with specific revision" option', () => {
+ const { getByLabelText } = renderComponent();
+ expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
+ });
+
+ it('should render user supplied checkbox when showUserSuppliedCheckbox is true', () => {
+ const { getByLabelText } = renderComponent({
+ showUserSuppliedCheckbox: true,
+ });
+ expect(getByLabelText('User defined only')).toBeInTheDocument();
+ });
+
+ it('should not render user supplied checkbox when showUserSuppliedCheckbox is false', () => {
+ const { queryByLabelText } = renderComponent({
+ showUserSuppliedCheckbox: false,
+ });
+ expect(queryByLabelText('User defined only')).not.toBeInTheDocument();
+ });
+
+ it('should call setDiffViewMode when a radio option is selected', async () => {
+ const user = userEvent.setup();
+ const setDiffViewMode = vi.fn();
+
+ const { getByLabelText } = renderComponent({
+ setDiffViewMode,
+ diffViewMode: 'view',
+ });
+
+ await user.click(getByLabelText('Diff with specific revision:'));
+ expect(setDiffViewMode).toHaveBeenCalledWith('specific');
+ });
+
+ it('should call setIsUserSupplied when checkbox is clicked', async () => {
+ const user = userEvent.setup();
+ const setIsUserSupplied = vi.fn();
+
+ const { getByLabelText } = renderComponent({
+ setIsUserSupplied,
+ isUserSupplied: false,
+ showUserSuppliedCheckbox: true,
+ });
+
+ await user.click(getByLabelText('User defined only'));
+ expect(setIsUserSupplied).toHaveBeenCalledWith(true);
+ });
+});
+
+describe('DiffWithSpecificRevision', () => {
+ it('should display input with compareRevisionNumber value when not NaN', () => {
+ const compareRevisionNumber = 3;
+ const { getByRole } = renderComponent({
+ diffViewMode: 'specific',
+ compareRevisionNumber,
+ });
+
+ const input = getByRole('spinbutton');
+ expect(input).toHaveValue(compareRevisionNumber);
+ });
+
+ it('should handle input values and constraints properly', () => {
+ const setCompareRevisionNumber = vi.fn();
+ const earliestRevisionNumber = 2;
+ const latestRevisionNumber = 10;
+
+ const { getByRole } = renderComponent({
+ diffViewMode: 'specific',
+ earliestRevisionNumber,
+ latestRevisionNumber,
+ setCompareRevisionNumber,
+ compareRevisionNumber: 4,
+ });
+
+ // Check that input has the right min/max attributes
+ const input = getByRole('spinbutton');
+ expect(input).toHaveAttribute('min', earliestRevisionNumber.toString());
+ expect(input).toHaveAttribute('max', latestRevisionNumber.toString());
+
+ fireEvent.change(input, { target: { valueAsNumber: 11 } });
+ expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
+ latestRevisionNumber
+ );
+
+ fireEvent.change(input, { target: { valueAsNumber: 1 } });
+ expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
+ earliestRevisionNumber
+ );
+
+ fireEvent.change(input, { target: { valueAsNumber: 5 } });
+ expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(5);
+ });
+
+ it('should handle NaN values in the input as empty string', () => {
+ const { getByRole } = renderComponent({
+ diffViewMode: 'specific',
+ compareRevisionNumber: NaN,
+ });
+
+ const input = getByRole('spinbutton') as HTMLInputElement;
+ expect(input.value).toBe('');
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx
new file mode 100644
index 000000000..62698c40f
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx
@@ -0,0 +1,146 @@
+import { ChangeEvent } from 'react';
+
+import { useDebounce } from '@/react/hooks/useDebounce';
+
+import { RadioGroup, RadioGroupOption } from '@@/RadioGroup/RadioGroup';
+import { Input } from '@@/form-components/Input';
+import { Checkbox } from '@@/form-components/Checkbox';
+
+import {
+ LatestRevisionNumber,
+ EarliestRevisionNumber,
+ CompareRevisionNumber,
+ SelectedRevisionNumber,
+} from './types';
+
+export type DiffViewMode = 'view' | 'previous' | 'specific';
+
+type Props = {
+ selectedRevisionNumber: SelectedRevisionNumber;
+ latestRevisionNumber: LatestRevisionNumber;
+ compareRevisionNumber: CompareRevisionNumber;
+ setCompareRevisionNumber: (
+ compareRevisionNumber: CompareRevisionNumber
+ ) => void;
+ earliestRevisionNumber: EarliestRevisionNumber;
+ diffViewMode: DiffViewMode;
+ setDiffViewMode: (diffViewMode: DiffViewMode) => void;
+ isUserSupplied?: boolean;
+ setIsUserSupplied?: (isUserSupplied: boolean) => void;
+ showUserSuppliedCheckbox?: boolean;
+};
+
+export function DiffControl({
+ selectedRevisionNumber,
+ latestRevisionNumber,
+ compareRevisionNumber,
+ setCompareRevisionNumber,
+ earliestRevisionNumber,
+ diffViewMode,
+ setDiffViewMode,
+ isUserSupplied,
+ setIsUserSupplied,
+ showUserSuppliedCheckbox,
+}: Props) {
+ // If there is a different version to compare, show view option radio group
+ const showViewOptions = latestRevisionNumber > earliestRevisionNumber;
+
+ // to show the previous option, the earliest revision number available must be less than the selected revision number. (compare is still allowed, because we can still compare with a later revision)
+ const disabledPreviousOption =
+ earliestRevisionNumber >= selectedRevisionNumber;
+
+ const options: Array> = [
+ { label: 'View', value: 'view' },
+ {
+ label: 'Diff with previous',
+ value: 'previous',
+ disabled: disabledPreviousOption,
+ },
+ {
+ label: (
+
+ ),
+ value: 'specific',
+ },
+ ];
+
+ return (
+
+ {showViewOptions && (
+
+ )}
+ {!!showUserSuppliedCheckbox && !!setIsUserSupplied && (
+ setIsUserSupplied(!isUserSupplied)}
+ data-cy="values-details-user-supplied"
+ className="font-normal control-label"
+ bold={false}
+ />
+ )}
+
+ );
+}
+
+function DiffWithSpecificRevision({
+ latestRevisionNumber,
+ earliestRevisionNumber,
+ compareRevisionNumber,
+ setCompareRevisionNumber,
+}: {
+ latestRevisionNumber: LatestRevisionNumber;
+ earliestRevisionNumber: EarliestRevisionNumber;
+ compareRevisionNumber: CompareRevisionNumber;
+ setCompareRevisionNumber: (
+ compareRevisionNumber: CompareRevisionNumber
+ ) => void;
+}) {
+ // the revision number is debounced to avoid too many requests to the backend
+ const [
+ debouncedSetCompareRevisionNumber,
+ setDebouncedSetCompareRevisionNumber,
+ ] = useDebounce(compareRevisionNumber, setCompareRevisionNumber, 500);
+
+ return (
+ <>
+ Diff with specific revision:
+
+ >
+ );
+
+ function handleSpecificRevisionChange(e: ChangeEvent) {
+ const inputNumber = e.target.valueAsNumber;
+ // handle out of range values
+ if (inputNumber > latestRevisionNumber) {
+ setCompareRevisionNumber(latestRevisionNumber);
+ return;
+ }
+ if (inputNumber < earliestRevisionNumber) {
+ setCompareRevisionNumber(earliestRevisionNumber);
+ return;
+ }
+ setDebouncedSetCompareRevisionNumber(inputNumber);
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx
new file mode 100644
index 000000000..7871cbfa3
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx
@@ -0,0 +1,55 @@
+import { AutomationTestingProps } from '@/types';
+
+import { DiffViewer } from '@@/CodeEditor/DiffViewer';
+import { Loading } from '@@/Widget';
+import { Alert } from '@@/Alert';
+
+import { CompareRevisionNumberFetched, SelectedRevisionNumber } from './types';
+
+interface Props extends AutomationTestingProps {
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ newText: string;
+ originalText: string;
+ id: string;
+}
+
+export function DiffViewSection({
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ compareRevisionNumberFetched,
+ selectedRevisionNumber,
+ newText,
+ originalText,
+ id,
+ 'data-cy': dataCy,
+}: Props) {
+ if (isCompareReleaseLoading) {
+ return ;
+ }
+
+ if (isCompareReleaseError) {
+ return Error loading compare values ;
+ }
+
+ return (
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
new file mode 100644
index 000000000..772774fe3
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
@@ -0,0 +1,242 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { HttpResponse } from 'msw';
+import { Event, EventList } from 'kubernetes-types/core/v1';
+
+import { server, http } from '@/setup-tests/server';
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { UserViewModel } from '@/portainer/models/user';
+import { withUserProvider } from '@/react/test-utils/withUserProvider';
+import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
+
+import { GenericResource } from '../../types';
+
+import {
+ HelmEventsDatatable,
+ filterRelatedEvents,
+} from './HelmEventsDatatable';
+
+const mockUseEnvironmentId = vi.fn();
+mockLocalizeDate();
+
+vi.mock('@/react/hooks/useEnvironmentId', () => ({
+ useEnvironmentId: () => mockUseEnvironmentId(),
+}));
+
+const testResources: GenericResource[] = [
+ {
+ kind: 'Deployment',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Running',
+ message: 'All replicas are ready',
+ },
+ },
+ metadata: {
+ name: 'test-deployment',
+ namespace: 'default',
+ uid: 'test-deployment-uid',
+ },
+ },
+ {
+ kind: 'Service',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Available',
+ message: 'Service is available',
+ },
+ },
+ metadata: {
+ name: 'test-service',
+ namespace: 'default',
+ uid: 'test-service-uid',
+ },
+ },
+];
+
+const mockEventsResponse: EventList = {
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: {
+ resourceVersion: '12345',
+ },
+ items: [
+ {
+ metadata: {
+ name: 'test-deployment-123456',
+ namespace: 'default',
+ uid: 'event-uid-1',
+ resourceVersion: '1000',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Deployment',
+ namespace: 'default',
+ name: 'test-deployment',
+ uid: 'test-deployment-uid',
+ apiVersion: 'apps/v1',
+ resourceVersion: '2000',
+ },
+ reason: 'ScalingReplicaSet',
+ message: 'Scaled up replica set test-deployment-abc123 to 1',
+ source: {
+ component: 'deployment-controller',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ type: 'Normal',
+ reportingComponent: 'deployment-controller',
+ reportingInstance: '',
+ },
+ {
+ metadata: {
+ name: 'test-service-123456',
+ namespace: 'default',
+ uid: 'event-uid-2',
+ resourceVersion: '1001',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Service',
+ namespace: 'default',
+ name: 'test-service',
+ uid: 'test-service-uid',
+ apiVersion: 'v1',
+ resourceVersion: '2001',
+ },
+ reason: 'CreatedLoadBalancer',
+ message: 'Created load balancer',
+ source: {
+ component: 'service-controller',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ type: 'Normal',
+ reportingComponent: 'service-controller',
+ reportingInstance: '',
+ },
+ ],
+};
+
+const mixedEventsResponse: EventList = {
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: {
+ resourceVersion: '12345',
+ },
+ items: [
+ {
+ metadata: {
+ name: 'test-deployment-123456',
+ namespace: 'default',
+ uid: 'event-uid-1',
+ resourceVersion: '1000',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Deployment',
+ namespace: 'default',
+ name: 'test-deployment',
+ uid: 'test-deployment-uid', // This matches a resource UID
+ apiVersion: 'apps/v1',
+ resourceVersion: '2000',
+ },
+ reason: 'ScalingReplicaSet',
+ message: 'Scaled up replica set test-deployment-abc123 to 1',
+ source: {
+ component: 'deployment-controller',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ type: 'Normal',
+ reportingComponent: 'deployment-controller',
+ reportingInstance: '',
+ },
+ {
+ metadata: {
+ name: 'unrelated-pod-123456',
+ namespace: 'default',
+ uid: 'event-uid-3',
+ resourceVersion: '1002',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Pod',
+ namespace: 'default',
+ name: 'unrelated-pod',
+ uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
+ apiVersion: 'v1',
+ resourceVersion: '2002',
+ },
+ reason: 'Scheduled',
+ message: 'Successfully assigned unrelated-pod to node',
+ source: {
+ component: 'default-scheduler',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ reportingComponent: 'scheduler',
+ reportingInstance: '',
+ },
+ ],
+};
+
+function renderComponent() {
+ const user = new UserViewModel({ Username: 'user' });
+ mockUseEnvironmentId.mockReturnValue(3);
+
+ const HelmEventsDatatableWithProviders = withTestQueryProvider(
+ withUserProvider(withTestRouter(HelmEventsDatatable), user)
+ );
+
+ return render(
+
+ );
+}
+
+describe('HelmEventsDatatable', () => {
+ beforeEach(() => {
+ server.use(
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () => HttpResponse.json(mockEventsResponse)
+ )
+ );
+ });
+
+ it('should render events datatable with correct title', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Events reflect the latest revision only.')
+ ).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ });
+
+ it('should correctly filter related events using the filterRelatedEvents function', () => {
+ const filteredEvents = filterRelatedEvents(
+ mixedEventsResponse.items as Event[],
+ testResources
+ );
+
+ expect(filteredEvents.length).toBe(1);
+ expect(filteredEvents[0].involvedObject.uid).toBe('test-deployment-uid');
+
+ const unrelatedEvents = filteredEvents.filter(
+ (e) => e.involvedObject.uid === 'unrelated-pod-uid'
+ );
+ expect(unrelatedEvents.length).toBe(0);
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
new file mode 100644
index 000000000..d02423d82
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
@@ -0,0 +1,77 @@
+import { compact } from 'lodash';
+import { Event } from 'kubernetes-types/core/v1';
+
+import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
+import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
+import { useEvents } from '@/react/kubernetes/queries/useEvents';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { useTableState } from '@@/datatables/useTableState';
+import { Widget } from '@@/Widget';
+import { TextTip } from '@@/Tip/TextTip';
+
+import { GenericResource } from '../../types';
+
+export const storageKey = 'k8sHelmEventsDatatable';
+export const settingsStore = createStore(storageKey, {
+ id: 'Date',
+ desc: true,
+});
+
+export function HelmEventsDatatable({
+ namespace,
+ releaseResources,
+}: {
+ namespace: string;
+ releaseResources: GenericResource[];
+}) {
+ const environmentId = useEnvironmentId();
+ const tableState = useTableState(settingsStore, storageKey);
+
+ const eventsQuery = useEvents(environmentId, {
+ namespace,
+ queryOptions: {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ select: (data) => filterRelatedEvents(data, releaseResources),
+ },
+ });
+
+ return (
+
+
+ Events reflect the latest revision only.
+
+ }
+ titleIcon={null}
+ tableState={tableState}
+ isLoading={eventsQuery.isInitialLoading}
+ data-cy="k8sAppDetail-eventsTable"
+ // no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
+ noWidget
+ />
+
+ );
+}
+
+export function useHelmEventsTableState() {
+ return useTableState(settingsStore, storageKey);
+}
+
+export function filterRelatedEvents(
+ events: Event[],
+ resources: GenericResource[]
+) {
+ const relatedUids = getReleaseUids(resources);
+ const relatedUidsSet = new Set(relatedUids);
+ return events.filter(
+ (event) =>
+ event.involvedObject.uid && relatedUidsSet.has(event.involvedObject.uid)
+ );
+}
+
+function getReleaseUids(resources: GenericResource[]) {
+ return compact(resources.map((resource) => resource.metadata.uid));
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
index c95886d3f..c87ad17a3 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
@@ -1,18 +1,58 @@
+import { ReactNode } from 'react';
+
import { CodeEditor } from '@@/CodeEditor';
+import { DiffViewMode } from './DiffControl';
+import { DiffViewSection } from './DiffViewSection';
+import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
+
type Props = {
manifest: string;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ diffViewMode: DiffViewMode;
+ compareManifest?: string;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ diffControl: ReactNode;
};
-export function ManifestDetails({ manifest }: Props) {
+export function ManifestDetails({
+ manifest,
+ selectedRevisionNumber,
+ diffViewMode,
+ compareManifest,
+ compareRevisionNumberFetched,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ diffControl,
+}: Props) {
return (
-
+ <>
+ {diffControl}
+ {diffViewMode === 'view' ? (
+
+ ) : (
+
+ )}
+ >
);
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
index 74a6135bd..be5feb0ca 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
@@ -1,9 +1,48 @@
import Markdown from 'markdown-to-jsx';
+import { ReactNode } from 'react';
+
+import { DiffViewMode } from './DiffControl';
+import { DiffViewSection } from './DiffViewSection';
+import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
type Props = {
notes: string;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ diffViewMode: DiffViewMode;
+ compareNotes?: string;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ diffControl: ReactNode;
};
-export function NotesDetails({ notes }: Props) {
- return {notes} ;
+export function NotesDetails({
+ notes,
+ selectedRevisionNumber,
+ diffViewMode,
+ compareNotes,
+ compareRevisionNumberFetched,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ diffControl,
+}: Props) {
+ return (
+ <>
+ {diffControl}
+ {diffViewMode === 'view' ? (
+ {notes}
+ ) : (
+
+ )}
+ >
+ );
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
index efa36c0c2..0aa05960c 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
@@ -1,32 +1,184 @@
import { useState } from 'react';
import { compact } from 'lodash';
+import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
+import { AlertTriangle } from 'lucide-react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useEvents } from '@/react/kubernetes/queries/useEvents';
import { NavTabs, Option } from '@@/NavTabs';
+import { Badge } from '@@/Badge';
+import { Icon } from '@@/Icon';
import { HelmRelease } from '../../types';
+import { useHelmHistory } from '../queries/useHelmHistory';
import { ManifestDetails } from './ManifestDetails';
import { NotesDetails } from './NotesDetails';
import { ValuesDetails } from './ValuesDetails';
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
+import { DiffControl, DiffViewMode } from './DiffControl';
+import { useHelmReleaseToCompare } from './useHelmReleaseToCompare';
+import {
+ filterRelatedEvents,
+ HelmEventsDatatable,
+ useHelmEventsTableState,
+} from './HelmEventsDatatable';
type Props = {
release: HelmRelease;
+ selectedRevision: number;
};
-type Tab = 'values' | 'notes' | 'manifest' | 'resources';
+type Tab = 'values' | 'notes' | 'manifest' | 'resources' | 'events';
+
+export function ReleaseTabs({ release, selectedRevision }: Props) {
+ const {
+ params: { tab },
+ } = useCurrentStateAndParams();
+ const router = useRouter();
+ const environmentId = useEnvironmentId();
+ // state is here so that the state isn't lost when the tab changes
+ const [isUserSupplied, setIsUserSupplied] = useState(true);
+ // start with NaN so that the input is empty (see for more details)
+ const [selectedCompareRevisionNumber, setSelectedCompareRevisionNumber] =
+ useState(NaN);
+ const [diffViewMode, setDiffViewMode] = useState('view');
+
+ const historyQuery = useHelmHistory(
+ environmentId,
+ release.name,
+ release.namespace ?? ''
+ );
+ const earliestRevisionNumber =
+ historyQuery.data?.[historyQuery.data.length - 1]?.version ??
+ release.version ??
+ 1;
+ const latestRevisionNumber =
+ historyQuery.data?.[0]?.version ?? release.version ?? 1;
+ const { compareRelease, isCompareReleaseLoading, isCompareReleaseError } =
+ useHelmReleaseToCompare(
+ release,
+ earliestRevisionNumber,
+ latestRevisionNumber,
+ diffViewMode,
+ selectedRevision,
+ selectedCompareRevisionNumber
+ );
+
+ const { autoRefreshRate } = useHelmEventsTableState();
+ const { data: eventWarningCount } = useEvents(environmentId, {
+ namespace: release.namespace ?? '',
+ queryOptions: {
+ autoRefreshRate: autoRefreshRate * 1000,
+ select: (data) => {
+ const relatedEvents = filterRelatedEvents(
+ data,
+ release.info?.resources ?? []
+ );
+ return relatedEvents.filter((e) => e.type === 'Warning').length;
+ },
+ },
+ });
+
+ return (
+
+ onSelect={setTab}
+ selectedId={parseValidTab(tab, !!release.info?.notes)}
+ type="pills"
+ justified
+ options={helmTabs(
+ release,
+ isUserSupplied,
+ setIsUserSupplied,
+ earliestRevisionNumber,
+ latestRevisionNumber,
+ selectedRevision,
+ selectedCompareRevisionNumber,
+ setSelectedCompareRevisionNumber,
+ diffViewMode,
+ handleDiffViewChange,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ eventWarningCount ?? 0,
+ compareRelease
+ )}
+ />
+ );
+
+ function handleDiffViewChange(diffViewMode: DiffViewMode) {
+ setDiffViewMode(diffViewMode);
+
+ if (latestRevisionNumber === earliestRevisionNumber) {
+ return;
+ }
+
+ // if the input for compare revision number is NaN, set it to the previous revision number
+ if (
+ Number.isNaN(selectedCompareRevisionNumber) &&
+ diffViewMode === 'specific'
+ ) {
+ if (selectedRevision > earliestRevisionNumber) {
+ setSelectedCompareRevisionNumber(selectedRevision - 1);
+ return;
+ }
+ // it could be useful to compare to the latest revision number if the selected revision number is the earliest revision number
+ setSelectedCompareRevisionNumber(latestRevisionNumber);
+ }
+ }
+
+ function setTab(tab: Tab) {
+ router.stateService.go('kubernetes.helm', {
+ tab,
+ });
+ }
+}
function helmTabs(
release: HelmRelease,
isUserSupplied: boolean,
- setIsUserSupplied: (isUserSupplied: boolean) => void
+ setIsUserSupplied: (isUserSupplied: boolean) => void,
+ earliestRevisionNumber: number,
+ latestRevisionNumber: number,
+ selectedRevisionNumber: number,
+ compareRevisionNumber: number,
+ setCompareRevisionNumber: (compareRevisionNumber: number) => void,
+ diffViewMode: DiffViewMode,
+ setDiffViewMode: (diffViewMode: DiffViewMode) => void,
+ isCompareReleaseLoading: boolean,
+ isCompareReleaseError: boolean,
+ eventWarningCount: number,
+ compareRelease?: HelmRelease
): Option[] {
+ // as long as the latest revision number is greater than the earliest revision number, there are changes to compare
+ const showDiffControl = latestRevisionNumber > earliestRevisionNumber;
+
return compact([
{
label: 'Resources',
id: 'resources',
children: ,
},
+ {
+ label: (
+ <>
+ Events
+ {eventWarningCount >= 1 && (
+
+
+ {eventWarningCount}
+
+ )}
+ >
+ ),
+ id: 'events',
+ children: (
+
+ ),
+ },
{
label: 'Values',
id: 'values',
@@ -34,35 +186,97 @@ function helmTabs(
+ }
/>
),
},
{
label: 'Manifest',
id: 'manifest',
- children: ,
+ children: (
+
+ )
+ }
+ />
+ ),
},
!!release.info?.notes && {
label: 'Notes',
id: 'notes',
- children: ,
+ children: (
+
+ )
+ }
+ />
+ ),
},
]);
}
-export function ReleaseTabs({ release }: Props) {
- const [tab, setTab] = useState('resources');
- // state is here so that the state isn't lost when the tab changes
- const [isUserSupplied, setIsUserSupplied] = useState(true);
-
- return (
-
- onSelect={setTab}
- selectedId={tab}
- type="pills"
- justified
- options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
- />
- );
+function parseValidTab(tab: string, hasNotes: boolean): Tab {
+ if (
+ tab === 'values' ||
+ (tab === 'notes' && hasNotes) ||
+ tab === 'manifest' ||
+ tab === 'resources' ||
+ tab === 'events'
+ ) {
+ return tab;
+ }
+ return 'resources';
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
index a1510aef8..4442ca871 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
@@ -6,7 +6,17 @@ import { DescribeModal } from './DescribeModal';
const mockUseDescribeResource = vi.fn();
-vi.mock('yaml-schema', () => ({}));
+// Mock the CodeEditor component instead of yaml-schema
+vi.mock('@@/CodeEditor', () => ({
+ CodeEditor: ({
+ value,
+ 'data-cy': dataCy,
+ }: {
+ value: string;
+ 'data-cy'?: string;
+ }) => {value}
,
+}));
+
vi.mock('./queries/useDescribeResource', () => ({
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
}));
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
index 6a3b35674..8469e38fe 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
@@ -11,6 +11,7 @@ import {
import { useTableState } from '@@/datatables/useTableState';
import { Widget } from '@@/Widget';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
+import { TextTip } from '@@/Tip/TextTip';
import { useHelmRelease } from '../../queries/useHelmRelease';
@@ -34,12 +35,14 @@ const settingsStore = createStore('helm-resources');
export function ResourcesTable() {
const environmentId = useEnvironmentId();
const { params } = useCurrentStateAndParams();
- const { name, namespace } = params;
+ const { name, namespace, revision } = params;
+ const revisionNumber = revision ? parseInt(revision, 10) : undefined;
const tableState = useTableState(settingsStore, storageKey);
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
showResources: true,
refetchInterval: tableState.autoRefreshRate * 1000,
+ revision: revisionNumber,
});
const rows = useResourceRows(helmReleaseQuery.data?.info?.resources);
@@ -48,11 +51,17 @@ export function ResourcesTable() {
+ Resources reflect the latest revision only.
+
+ }
disableSelect
getRowId={(row) => row.id}
data-cy="helm-resources-datatable"
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
index b9ff43fff..cded59796 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
@@ -14,5 +14,9 @@ export const status = columnHelper.accessor((row) => row.status.label, {
function Cell({ row }: CellContext) {
const { status } = row.original;
+ if (!status.label) {
+ return '-';
+ }
+
return {status.label} ;
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
index b1f32ca3f..7a480660c 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
@@ -1,44 +1,74 @@
-import { Checkbox } from '@@/form-components/Checkbox';
+import { ReactNode } from 'react';
+
import { CodeEditor } from '@@/CodeEditor';
import { Values } from '../../types';
+import { DiffViewMode } from './DiffControl';
+import { DiffViewSection } from './DiffViewSection';
+import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
+
interface Props {
values?: Values;
isUserSupplied: boolean;
- setIsUserSupplied: (isUserSupplied: boolean) => void;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ diffViewMode: DiffViewMode;
+ compareValues?: Values;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ diffControl: ReactNode;
}
-const noValuesMessage = 'No values found';
-
export function ValuesDetails({
values,
isUserSupplied,
- setIsUserSupplied,
+ selectedRevisionNumber,
+ diffViewMode,
+ compareValues,
+ compareRevisionNumberFetched,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ diffControl,
}: Props) {
return (
-
- {/* bring in line with the code editor copy button */}
-
- setIsUserSupplied(!isUserSupplied)}
- data-cy="values-details-user-supplied"
+ <>
+ {diffControl}
+ {diffViewMode === 'view' ? (
+
-
-
-
+ ) : (
+
+ )}
+ >
);
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts
new file mode 100644
index 000000000..0b038a0f5
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts
@@ -0,0 +1,26 @@
+// exporting as types here allows the JSDocs to be reused, improving readability
+
+/**
+ * The revision number of the latest release.
+ */
+export type LatestRevisionNumber = number;
+
+/**
+ * The revision number selected in the UI.
+ */
+export type SelectedRevisionNumber = number;
+
+/**
+ * The revision number to compare with.
+ */
+export type CompareRevisionNumber = number;
+
+/**
+ * The earliest revision number available for the chart.
+ */
+export type EarliestRevisionNumber = number;
+
+/**
+ * The revision number that's being fetched (instead of the form state).
+ */
+export type CompareRevisionNumberFetched = number;
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts
new file mode 100644
index 000000000..814f03a6a
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts
@@ -0,0 +1,60 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { HelmRelease } from '../../types';
+import { useHelmRelease } from '../queries/useHelmRelease';
+
+import { DiffViewMode } from './DiffControl';
+
+/** useHelmReleaseToCompare is a hook that returns the release to compare to based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber */
+export function useHelmReleaseToCompare(
+ release: HelmRelease,
+ earliestRevisionNumber: number,
+ latestRevisionNumber: number,
+ diffViewMode: DiffViewMode,
+ selectedRevisionNumber: number,
+ selectedCompareRevisionNumber: number
+) {
+ const environmentId = useEnvironmentId();
+ // the selectedCompareRevisionNumber is the number selected in the input field, but the compareRevisionNumber is the revision number of the release to compare to
+ const compareRevisionNumber = getCompareReleaseVersion(
+ diffViewMode,
+ selectedRevisionNumber,
+ selectedCompareRevisionNumber
+ );
+ const enabled =
+ compareRevisionNumber <= latestRevisionNumber &&
+ compareRevisionNumber >= earliestRevisionNumber;
+
+ // a 1 hour stale time is nice because past releases are not likely to change
+ const compareReleaseQuery = useHelmRelease(
+ environmentId,
+ release.name,
+ release.namespace ?? '',
+ {
+ showResources: false,
+ enabled,
+ staleTime: 60 * 60 * 1000,
+ revision: compareRevisionNumber,
+ }
+ );
+ return {
+ compareRelease: compareReleaseQuery.data,
+ isCompareReleaseLoading: compareReleaseQuery.isInitialLoading,
+ isCompareReleaseError: compareReleaseQuery.isError,
+ };
+}
+
+// getCompareReleaseVersion is a helper function that returns the revision number that should be fetched based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber
+function getCompareReleaseVersion(
+ diffViewMode: DiffViewMode,
+ selectedRevisionNumber: number,
+ selectedCompareRevisionNumber: number
+) {
+ if (diffViewMode === 'previous') {
+ return selectedRevisionNumber - 1;
+ }
+ if (diffViewMode === 'specific') {
+ return selectedCompareRevisionNumber;
+ }
+ return selectedRevisionNumber;
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts
new file mode 100644
index 000000000..41d6ab375
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts
@@ -0,0 +1,44 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { withGlobalError } from '@/react-tools/react-query';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+
+import { HelmRelease } from '../../types';
+
+export function useHelmHistory(
+ environmentId: EnvironmentId,
+ name: string,
+ namespace: string
+) {
+ return useQuery(
+ [environmentId, 'helm', 'releases', namespace, name, 'history'],
+ () => getHelmHistory(environmentId, name, namespace),
+ {
+ enabled: !!environmentId && !!name && !!namespace,
+ ...withGlobalError('Unable to retrieve helm application history'),
+ retry: 3,
+ // occasionally the application shows before the release is created, take some more time to refetch
+ retryDelay: 2000,
+ }
+ );
+}
+
+async function getHelmHistory(
+ environmentId: EnvironmentId,
+ name: string,
+ namespace: string
+) {
+ try {
+ const response = await axios.get(
+ `endpoints/${environmentId}/kubernetes/helm/${name}/history`,
+ {
+ params: { namespace },
+ }
+ );
+
+ return response.data;
+ } catch (error) {
+ throw parseAxiosError(error, 'Unable to retrieve helm application history');
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
index 8d41e6544..cdf465c16 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
@@ -6,6 +6,15 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { HelmRelease } from '../../types';
+type Options = {
+ select?: (data: HelmRelease) => T;
+ showResources?: boolean;
+ refetchInterval?: number;
+ enabled?: boolean;
+ staleTime?: number;
+ /** when the revision is undefined, the latest revision is fetched */
+ revision?: number;
+};
/**
* React hook to fetch a specific Helm release
*/
@@ -13,39 +22,52 @@ export function useHelmRelease(
environmentId: EnvironmentId,
name: string,
namespace: string,
- options: {
- select?: (data: HelmRelease) => T;
- showResources?: boolean;
- refetchInterval?: number;
- } = {}
+ options: Options = {}
) {
- const { select, showResources, refetchInterval } = options;
+ const { select, showResources, refetchInterval, revision, staleTime } =
+ options;
return useQuery(
- [environmentId, 'helm', 'releases', namespace, name, options.showResources],
+ [
+ environmentId,
+ 'helm',
+ 'releases',
+ namespace,
+ name,
+ revision,
+ showResources,
+ ],
() =>
getHelmRelease(environmentId, name, {
namespace,
showResources,
+ revision,
}),
{
- enabled: !!environmentId && !!name && !!namespace,
+ enabled: !!environmentId && !!name && !!namespace && options.enabled,
...withGlobalError('Unable to retrieve helm application details'),
+ retry: 3,
+ // occasionally the application shows before the release is created, take some more time to refetch
+ retryDelay: 2000,
select,
refetchInterval,
+ staleTime,
}
);
}
+type Params = {
+ namespace: string;
+ showResources?: boolean;
+ revision?: number;
+};
+
/**
* Get a specific Helm release
*/
async function getHelmRelease(
environmentId: EnvironmentId,
name: string,
- params: {
- namespace: string;
- showResources?: boolean;
- }
+ params: Params
) {
try {
const { data } = await axios.get(
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
new file mode 100644
index 000000000..733b79dea
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
@@ -0,0 +1,99 @@
+import { useQuery, useQueries } from '@tanstack/react-query';
+import { useMemo } from 'react';
+import { compact, flatMap } from 'lodash';
+
+import { withGlobalError } from '@/react-tools/react-query';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { useCurrentUser } from '@/react/hooks/useUser';
+
+import { getHelmRepositories } from '../../queries/useHelmChartList';
+
+interface HelmSearch {
+ entries: Entries;
+}
+
+interface Entries {
+ [key: string]: { version: string }[];
+}
+
+export interface ChartVersion {
+ Repo: string;
+ Version: string;
+}
+
+/**
+ * Hook to fetch all Helm repositories for the current user
+ */
+export function useHelmRepositories() {
+ const { user } = useCurrentUser();
+ return useQuery(
+ ['helm', 'repositories'],
+ async () => getHelmRepositories(user.Id),
+ {
+ enabled: !!user.Id,
+ ...withGlobalError('Unable to retrieve helm repositories'),
+ }
+ );
+}
+
+/**
+ * React hook to get a list of available versions for a chart from specified repositories
+ *
+ * @param chart The chart name to get versions for
+ * @param repositories Array of repository URLs to search in
+ */
+export function useHelmRepoVersions(
+ chart: string,
+ staleTime: number,
+ repositories: string[] = []
+) {
+ // Fetch versions from each repository in parallel as separate queries
+ const versionQueries = useQueries({
+ queries: useMemo(
+ () =>
+ repositories.map((repo) => ({
+ queryKey: ['helm', 'repositories', chart, repo],
+ queryFn: () => getSearchHelmRepo(repo, chart),
+ enabled: !!chart && repositories.length > 0,
+ staleTime,
+ ...withGlobalError(`Unable to retrieve versions from ${repo}`),
+ })),
+ [repositories, chart, staleTime]
+ ),
+ });
+
+ // Combine the results from all repositories for easier consumption
+ const allVersions = useMemo(() => {
+ const successfulResults = compact(versionQueries.map((q) => q.data));
+ return flatMap(successfulResults);
+ }, [versionQueries]);
+
+ return {
+ data: allVersions,
+ isInitialLoading: versionQueries.some((q) => q.isLoading),
+ isError: versionQueries.some((q) => q.isError),
+ };
+}
+
+/**
+ * Get Helm repositories for user
+ */
+async function getSearchHelmRepo(
+ repo: string,
+ chart: string
+): Promise {
+ try {
+ const { data } = await axios.get(`templates/helm`, {
+ params: { repo, chart },
+ });
+ const versions = data.entries[chart];
+ return (
+ versions?.map((v) => ({
+ Repo: repo,
+ Version: v.version,
+ })) ?? []
+ );
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts
new file mode 100644
index 000000000..00e9f8131
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts
@@ -0,0 +1,44 @@
+import { useQueryClient, useMutation } from '@tanstack/react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
+import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { HelmRelease } from '../../types';
+
+export interface UpdateHelmReleasePayload {
+ namespace: string;
+ values?: string;
+ repo?: string;
+ name: string;
+ chart: string;
+ version?: string;
+}
+export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (payload: UpdateHelmReleasePayload) =>
+ updateHelmRelease(environmentId, payload),
+ ...withInvalidate(queryClient, [
+ [environmentId, 'helm', 'releases'],
+ applicationsQueryKeys.applications(environmentId),
+ ]),
+ ...withGlobalError('Unable to uninstall helm application'),
+ });
+}
+
+async function updateHelmRelease(
+ environmentId: EnvironmentId,
+ payload: UpdateHelmReleasePayload
+) {
+ try {
+ const { data } = await axios.post(
+ `endpoints/${environmentId}/kubernetes/helm`,
+ payload
+ );
+ return data;
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to update helm release');
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
index c8def2077..5fb88d64c 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
@@ -3,8 +3,11 @@ import { useState } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Chart } from '../types';
+import {
+ useHelmChartList,
+ useHelmRepositories,
+} from '../queries/useHelmChartList';
-import { useHelmChartList } from './queries/useHelmChartList';
import { HelmTemplatesList } from './HelmTemplatesList';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
@@ -18,10 +21,8 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
const [selectedChart, setSelectedChart] = useState(null);
const { user } = useCurrentUser();
- const { data: charts = [], isLoading: chartsLoading } = useHelmChartList(
- user.Id
- );
-
+ const helmReposQuery = useHelmRepositories(user.Id);
+ const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []);
function clearHelmChart() {
setSelectedChart(null);
onSelectHelmChart('');
@@ -44,9 +45,9 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
/>
) : (
)}
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
index 3c080d077..f02d83131 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
@@ -50,7 +50,7 @@ function renderComponent({
withUserProvider(
withTestRouter(() => (
@@ -137,10 +137,10 @@ describe('HelmTemplatesList', () => {
});
it('should show loading message when loading prop is true', async () => {
- renderComponent({ loading: true });
+ renderComponent({ loading: true, charts: [] });
// Check for loading message
- expect(screen.getByText('Loading...')).toBeInTheDocument();
+ expect(screen.getByText('Loading helm charts...')).toBeInTheDocument();
expect(
screen.getByText('Initial download of Helm charts can take a few minutes')
).toBeInTheDocument();
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
index 3d9034f4c..c41b0acbb 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
@@ -5,13 +5,14 @@ import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar';
+import { InlineLoader } from '@@/InlineLoader';
import { Chart } from '../types';
import { HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props {
- loading: boolean;
+ isLoading: boolean;
charts?: Chart[];
selectAction: (chart: Chart) => void;
}
@@ -70,7 +71,7 @@ function getFilteredCharts(
}
export function HelmTemplatesList({
- loading,
+ isLoading,
charts = [],
selectAction,
}: Props) {
@@ -159,16 +160,20 @@ export function HelmTemplatesList({
No Helm charts found
)}
- {loading && (
-
- Loading...
-
- Initial download of Helm charts can take a few minutes
-
+ {isLoading && (
+
+
+ Loading helm charts...
+
+ {charts.length === 0 && (
+
+ Initial download of Helm charts can take a few minutes
+
+ )}
)}
- {!loading && charts.length === 0 && (
+ {!isLoading && charts.length === 0 && (
No helm charts available.
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
index 655b1d928..215838ff8 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
@@ -106,10 +106,7 @@ describe('HelmTemplatesSelectedItem', () => {
renderComponent();
const user = userEvent.setup();
- // First show the editor
- await user.click(await screen.findByText('Custom values'));
-
- // Verify editor is visible
+ // Verify editor is visible by default
expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
// Now hide the editor
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
index 088c0c69a..baffb9655 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
@@ -143,7 +143,12 @@ export function HelmTemplatesSelectedItem({
{({ values, setFieldValue }) => (