1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-30 18:59:41 +02:00

feat(helm): show manifest previews/changes when installing and upgrading a helm chart [r8s-405] (#898)

This commit is contained in:
Ali 2025-07-23 10:52:58 +12:00 committed by GitHub
parent a4cff13531
commit 60bc04bc33
41 changed files with 763 additions and 157 deletions

View file

@ -46,18 +46,24 @@ var errChartNameInvalid = errors.New("invalid chart name. " +
// @produce json // @produce json
// @param id path int true "Environment(Endpoint) identifier" // @param id path int true "Environment(Endpoint) identifier"
// @param payload body installChartPayload true "Chart details" // @param payload body installChartPayload true "Chart details"
// @param dryRun query bool false "Dry run"
// @success 201 {object} release.Release "Created" // @success 201 {object} release.Release "Created"
// @failure 401 "Unauthorized" // @failure 401 "Unauthorized"
// @failure 404 "Environment(Endpoint) or ServiceAccount not found" // @failure 404 "Environment(Endpoint) or ServiceAccount not found"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /endpoints/{id}/kubernetes/helm [post] // @router /endpoints/{id}/kubernetes/helm [post]
func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
dryRun, err := request.RetrieveBooleanQueryParameter(r, "dryRun", true)
if err != nil {
return httperror.BadRequest("Invalid dryRun query parameter", err)
}
var payload installChartPayload var payload installChartPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid Helm install payload", err) return httperror.BadRequest("Invalid Helm install payload", err)
} }
release, err := handler.installChart(r, payload) release, err := handler.installChart(r, payload, dryRun)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to install a chart", err) return httperror.InternalServerError("Unable to install a chart", err)
} }
@ -94,7 +100,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
return nil return nil
} }
func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) { func (handler *Handler) installChart(r *http.Request, p installChartPayload, dryRun bool) (*release.Release, error) {
clusterAccess, httperr := handler.getHelmClusterAccess(r) clusterAccess, httperr := handler.getHelmClusterAccess(r)
if httperr != nil { if httperr != nil {
return nil, httperr.Err return nil, httperr.Err
@ -107,6 +113,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
Namespace: p.Namespace, Namespace: p.Namespace,
Repo: p.Repo, Repo: p.Repo,
Atomic: p.Atomic, Atomic: p.Atomic,
DryRun: dryRun,
KubernetesClusterAccess: clusterAccess, KubernetesClusterAccess: clusterAccess,
} }
@ -134,13 +141,14 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
return nil, err return nil, err
} }
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest) if !installOpts.DryRun {
if err != nil { manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
return nil, err if err != nil {
} return nil, err
}
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil { if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
return nil, err return nil, err
}
} }
return release, nil return release, nil

View file

@ -122,7 +122,7 @@ test('should apply custom height', async () => {
<CodeEditor {...defaultProps} height={customHeight} /> <CodeEditor {...defaultProps} height={customHeight} />
); );
const editor = (await findByRole('textbox')).parentElement?.parentElement; const editor = await findByRole('textbox');
expect(editor).toHaveStyle({ height: customHeight }); expect(editor).toHaveStyle({ height: customHeight });
}); });

View file

@ -48,7 +48,7 @@ function yamlLanguage(schema?: JSONSchema7) {
syntaxHighlighting(oneDarkHighlightStyle), syntaxHighlighting(oneDarkHighlightStyle),
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor // explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
lineNumbers(), lineNumbers(),
lintGutter(), !!schema && lintGutter(),
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]), keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
// only show completions when a schema is provided // only show completions when a schema is provided
!!schema && !!schema &&

View file

@ -12,6 +12,7 @@ interface Props {
titleClassName?: string; titleClassName?: string;
className?: string; className?: string;
htmlFor?: string; htmlFor?: string;
setIsDefaultFolded?: (isDefaultFolded: boolean) => void;
} }
export function FormSection({ export function FormSection({
@ -23,6 +24,7 @@ export function FormSection({
titleClassName, titleClassName,
className, className,
htmlFor = '', htmlFor = '',
setIsDefaultFolded,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
const [isExpanded, setIsExpanded] = useState(!defaultFolded); const [isExpanded, setIsExpanded] = useState(!defaultFolded);
const id = `foldingButton${title}`; const id = `foldingButton${title}`;
@ -39,7 +41,10 @@ export function FormSection({
isExpanded={isExpanded} isExpanded={isExpanded}
data-cy={id} data-cy={id}
id={id} id={id}
onClick={() => setIsExpanded((isExpanded) => !isExpanded)} onClick={() => {
setIsExpanded((isExpanded) => !isExpanded);
setIsDefaultFolded?.(isExpanded);
}}
/> />
)} )}

View file

@ -7,7 +7,7 @@ import { getAllSettledItems } from '@/portainer/helpers/promise-utils';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { uninstallHelmApplication } from '@/react/kubernetes/helm/HelmApplicationView/queries/useUninstallHelmAppMutation'; import { uninstallHelmApplication } from '@/react/kubernetes/helm/helmReleaseQueries/useUninstallHelmAppMutation';
import { parseKubernetesAxiosError } from '../../axiosError'; import { parseKubernetesAxiosError } from '../../axiosError';
import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types'; import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types';

View file

@ -9,7 +9,7 @@ import { buildConfirmButton } from '@@/modals/utils';
import { confirm } from '@@/modals/confirm'; import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals'; import { ModalType } from '@@/modals';
import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation'; import { useHelmRollbackMutation } from '../../helmReleaseQueries/useHelmRollbackMutation';
type Props = { type Props = {
latestRevision: number; latestRevision: number;

View file

@ -5,7 +5,7 @@ import { notifySuccess } from '@/portainer/services/notifications';
import { DeleteButton } from '@@/buttons/DeleteButton'; import { DeleteButton } from '@@/buttons/DeleteButton';
import { useUninstallHelmAppMutation } from '../queries/useUninstallHelmAppMutation'; import { useUninstallHelmAppMutation } from '../../helmReleaseQueries/useUninstallHelmAppMutation';
export function UninstallButton({ export function UninstallButton({
environmentId, environmentId,

View file

@ -9,7 +9,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { import {
useHelmRepoVersions, useHelmRepoVersions,
ChartVersion, ChartVersion,
} from '../../queries/useHelmRepoVersions'; } from '../../helmChartSourceQueries/useHelmRepoVersions';
import { HelmRelease } from '../../types'; import { HelmRelease } from '../../types';
import { openUpgradeHelmModal } from './UpgradeHelmModal'; import { openUpgradeHelmModal } from './UpgradeHelmModal';
@ -25,32 +25,56 @@ vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(), notifySuccess: vi.fn(),
})); }));
vi.mock('../../queries/useHelmRepositories', () => ({ vi.mock('../../helmChartSourceQueries/useHelmRepositories', async () => {
useUserHelmRepositories: vi.fn(() => ({ const actual = await vi.importActual(
data: ['repo1', 'repo2'], '../../helmChartSourceQueries/useHelmRepositories'
isInitialLoading: false, );
isError: false, return {
})), ...actual,
})); useUserHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'],
isInitialLoading: false,
isError: false,
})),
};
});
vi.mock('../../queries/useHelmRepoVersions', () => ({ vi.mock('../../helmChartSourceQueries/useHelmRepoVersions', async () => {
useHelmRepoVersions: vi.fn(), const actual = await vi.importActual(
})); '../../helmChartSourceQueries/useHelmRepoVersions'
);
return {
...actual,
useHelmRepoVersions: vi.fn(),
};
});
// Mock the useHelmRelease hook // Mock the useHelmRelease hook
vi.mock('../queries/useHelmRelease', () => ({ vi.mock('../../helmReleaseQueries/useHelmRelease', async () => {
useHelmRelease: vi.fn(() => ({ const actual = await vi.importActual(
data: '1.0.0', '../../helmReleaseQueries/useHelmRelease'
})), );
})); return {
...actual,
useHelmRelease: vi.fn(() => ({
data: '1.0.0',
})),
};
});
// Mock the useUpdateHelmReleaseMutation hook // Mock the useUpdateHelmReleaseMutation hook
vi.mock('../../queries/useUpdateHelmReleaseMutation', () => ({ vi.mock('../../helmReleaseQueries/useUpdateHelmReleaseMutation', async () => {
useUpdateHelmReleaseMutation: vi.fn(() => ({ const actual = await vi.importActual(
mutate: vi.fn(), '../../helmReleaseQueries/useUpdateHelmReleaseMutation'
isLoading: false, );
})), return {
})); ...actual,
useUpdateHelmReleaseMutation: vi.fn(() => ({
mutate: vi.fn(),
isLoading: false,
})),
};
});
function renderButton(props = {}) { function renderButton(props = {}) {
const defaultProps = { const defaultProps = {
@ -157,12 +181,14 @@ describe('UpgradeButton', () => {
metadata: { metadata: {
name: 'test-chart', name: 'test-chart',
version: '1.0.0', version: '1.0.0',
appVersion: '1.0.0',
}, },
}, },
values: { values: {
userSuppliedValues: '{}', userSuppliedValues: '{}',
}, },
manifest: '', manifest: '',
namespace: 'default',
} as HelmRelease; } as HelmRelease;
vi.mocked(useHelmRepoVersions).mockReturnValue({ vi.mocked(useHelmRepoVersions).mockReturnValue({
@ -193,7 +219,9 @@ describe('UpgradeButton', () => {
expect.arrayContaining([ expect.arrayContaining([
{ Version: '1.0.0', Repo: 'stable' }, { Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' }, { Version: '1.1.0', Repo: 'stable' },
]) ]),
'', // releaseManifest
1 // environmentId
); );
}); });
}); });

View file

@ -12,10 +12,13 @@ import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { HelmRelease, UpdateHelmReleasePayload } from '../../types'; import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation'; import { useUpdateHelmReleaseMutation } from '../../helmReleaseQueries/useUpdateHelmReleaseMutation';
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions'; import { useHelmRepoVersions } from '../../helmChartSourceQueries/useHelmRepoVersions';
import { useHelmRelease } from '../queries/useHelmRelease'; import { useHelmRelease } from '../../helmReleaseQueries/useHelmRelease';
import { useUserHelmRepositories } from '../../queries/useHelmRepositories'; import {
flattenHelmRegistries,
useUserHelmRepositories,
} from '../../helmChartSourceQueries/useHelmRepositories';
import { openUpgradeHelmModal } from './UpgradeHelmModal'; import { openUpgradeHelmModal } from './UpgradeHelmModal';
@ -36,7 +39,9 @@ export function UpgradeButton({
const [useCache, setUseCache] = useState(true); const [useCache, setUseCache] = useState(true);
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId); const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const userRepositoriesQuery = useUserHelmRepositories(); const userRepositoriesQuery = useUserHelmRepositories({
select: flattenHelmRegistries,
});
const helmRepoVersionsQuery = useHelmRepoVersions( const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '', release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour 60 * 60 * 1000, // 1 hour
@ -164,7 +169,9 @@ export function UpgradeButton({
async function handleUpgrade() { async function handleUpgrade() {
const submittedUpgradeValues = await openUpgradeHelmModal( const submittedUpgradeValues = await openUpgradeHelmModal(
editableHelmRelease, editableHelmRelease,
filteredVersions filteredVersions,
release?.manifest || '',
environmentId
); );
if (submittedUpgradeValues) { if (submittedUpgradeValues) {

View file

@ -1,9 +1,10 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { ArrowUp } from 'lucide-react'; import { ArrowUp } from 'lucide-react';
import { withReactQuery } from '@/react-tools/withReactQuery'; import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ChartVersion } from '@/react/kubernetes/helm/queries/useHelmRepoVersions'; import { ChartVersion } from '@/react/kubernetes/helm/helmChartSourceQueries/useHelmRepoVersions';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Modal, OnSubmit, openModal } from '@@/modals'; import { Modal, OnSubmit, openModal } from '@@/modals';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
@ -15,26 +16,32 @@ import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { UpdateHelmReleasePayload } from '../../types'; import { UpdateHelmReleasePayload } from '../../types';
import { HelmValuesInput } from '../../components/HelmValuesInput'; import { HelmValuesInput } from '../../components/HelmValuesInput';
import { useHelmChartValues } from '../../queries/useHelmChartValues'; import { useHelmChartValues } from '../../helmChartSourceQueries/useHelmChartValues';
import { ManifestPreviewFormSection } from '../../components/ManifestPreviewFormSection';
interface Props { interface Props {
onSubmit: OnSubmit<UpdateHelmReleasePayload>; onSubmit: OnSubmit<UpdateHelmReleasePayload>;
payload: UpdateHelmReleasePayload; helmReleaseInitialValues: UpdateHelmReleasePayload;
releaseManifest: string;
versions: ChartVersion[]; versions: ChartVersion[];
chartName: string; chartName: string;
environmentId: EnvironmentId;
} }
export function UpgradeHelmModal({ export function UpgradeHelmModal({
payload, helmReleaseInitialValues,
releaseManifest,
versions, versions,
onSubmit, onSubmit,
chartName, chartName,
environmentId,
}: Props) { }: Props) {
const versionOptions: Option<ChartVersion>[] = versions.map((version) => { const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
const repo = payload.repo === version.Repo ? version.Repo : ''; const repo =
helmReleaseInitialValues.repo === version.Repo ? version.Repo : '';
const isCurrentVersion = const isCurrentVersion =
version.AppVersion === payload.appVersion && version.AppVersion === helmReleaseInitialValues.appVersion &&
version.Version === payload.version; version.Version === helmReleaseInitialValues.version;
const label = `${repo}@${version.Version}${ const label = `${repo}@${version.Version}${
isCurrentVersion ? ' (current)' : '' isCurrentVersion ? ' (current)' : ''
@ -50,13 +57,16 @@ export function UpgradeHelmModal({
const defaultVersion = const defaultVersion =
versionOptions.find( versionOptions.find(
(v) => (v) =>
v.value.AppVersion === payload.appVersion && v.value.AppVersion === helmReleaseInitialValues.appVersion &&
v.value.Version === payload.version && v.value.Version === helmReleaseInitialValues.version &&
v.value.Repo === payload.repo v.value.Repo === helmReleaseInitialValues.repo
)?.value || versionOptions[0]?.value; )?.value || versionOptions[0]?.value;
const [version, setVersion] = useState<ChartVersion>(defaultVersion); const [version, setVersion] = useState<ChartVersion>(defaultVersion);
const [userValues, setUserValues] = useState<string>(payload.values || ''); const [userValues, setUserValues] = useState<string>(
helmReleaseInitialValues.values || ''
);
const [atomic, setAtomic] = useState<boolean>(true); const [atomic, setAtomic] = useState<boolean>(true);
const [previewIsValid, setPreviewIsValid] = useState<boolean>(false);
const chartValuesRefQuery = useHelmChartValues({ const chartValuesRefQuery = useHelmChartValues({
chart: chartName, chart: chartName,
@ -64,6 +74,19 @@ export function UpgradeHelmModal({
version: version.Version, version: version.Version,
}); });
const submitPayload = useMemo(
() => ({
name: helmReleaseInitialValues.name,
values: userValues,
namespace: helmReleaseInitialValues.namespace,
chart: helmReleaseInitialValues.chart,
repo: version.Repo,
version: version.Version,
atomic,
}),
[helmReleaseInitialValues, userValues, version, atomic]
);
return ( return (
<Modal <Modal
onDismiss={() => onSubmit()} onDismiss={() => onSubmit()}
@ -84,7 +107,7 @@ export function UpgradeHelmModal({
> >
<Input <Input
id="release-name-input" id="release-name-input"
value={payload.name} value={helmReleaseInitialValues.name}
readOnly readOnly
disabled disabled
data-cy="helm-release-name-input" data-cy="helm-release-name-input"
@ -97,7 +120,7 @@ export function UpgradeHelmModal({
> >
<Input <Input
id="namespace-input" id="namespace-input"
value={payload.namespace} value={helmReleaseInitialValues.namespace}
readOnly readOnly
disabled disabled
data-cy="helm-namespace-input" data-cy="helm-namespace-input"
@ -134,6 +157,15 @@ export function UpgradeHelmModal({
valuesRef={chartValuesRefQuery.data?.values ?? ''} valuesRef={chartValuesRefQuery.data?.values ?? ''}
isValuesRefLoading={chartValuesRefQuery.isInitialLoading} isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
/> />
<div className="mb-10">
<ManifestPreviewFormSection
payload={submitPayload}
onChangePreviewValidation={setPreviewIsValid}
title="Manifest changes"
currentManifest={releaseManifest}
environmentId={environmentId}
/>
</div>
</div> </div>
</Modal.Body> </Modal.Body>
</div> </div>
@ -149,17 +181,8 @@ export function UpgradeHelmModal({
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={() => onClick={() => onSubmit(submitPayload)}
onSubmit({ disabled={!previewIsValid}
name: payload.name,
values: userValues,
namespace: payload.namespace,
chart: payload.chart,
repo: version.Repo,
version: version.Version,
atomic,
})
}
color="primary" color="primary"
key="update-button" key="update-button"
size="medium" size="medium"
@ -174,12 +197,16 @@ export function UpgradeHelmModal({
} }
export async function openUpgradeHelmModal( export async function openUpgradeHelmModal(
payload: UpdateHelmReleasePayload, helmReleaseInitialValues: UpdateHelmReleasePayload,
versions: ChartVersion[] versions: ChartVersion[],
releaseManifest: string,
environmentId: EnvironmentId
) { ) {
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), { return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
payload, helmReleaseInitialValues,
versions, versions,
chartName: payload.chart, chartName: helmReleaseInitialValues.chart,
releaseManifest,
environmentId,
}); });
} }

View file

@ -12,14 +12,14 @@ import { Alert } from '@@/Alert';
import { HelmRelease } from '../types'; import { HelmRelease } from '../types';
import { useIsSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace'; import { useIsSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace';
import { useHelmRelease } from '../helmReleaseQueries/useHelmRelease';
import { useHelmHistory } from '../helmReleaseQueries/useHelmHistory';
import { HelmSummary } from './HelmSummary'; import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs'; import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
import { useHelmRelease } from './queries/useHelmRelease';
import { ChartActions } from './ChartActions/ChartActions'; import { ChartActions } from './ChartActions/ChartActions';
import { HelmRevisionList } from './HelmRevisionList'; import { HelmRevisionList } from './HelmRevisionList';
import { HelmRevisionListSheet } from './HelmRevisionListSheet'; import { HelmRevisionListSheet } from './HelmRevisionListSheet';
import { useHelmHistory } from './queries/useHelmHistory';
export function HelmApplicationView() { export function HelmApplicationView() {
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();

View file

@ -11,7 +11,7 @@ import { Badge } from '@@/Badge';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import { HelmRelease } from '../../types'; import { HelmRelease } from '../../types';
import { useHelmHistory } from '../queries/useHelmHistory'; import { useHelmHistory } from '../../helmReleaseQueries/useHelmHistory';
import { ManifestDetails } from './ManifestDetails'; import { ManifestDetails } from './ManifestDetails';
import { NotesDetails } from './NotesDetails'; import { NotesDetails } from './NotesDetails';

View file

@ -3,8 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { GenericResource } from '@/react/kubernetes/helm/types';
import { GenericResource } from '../../../types';
import { ResourcesTable } from './ResourcesTable'; import { ResourcesTable } from './ResourcesTable';
@ -22,7 +21,7 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(), useEnvironmentId: () => mockUseEnvironmentId(),
})); }));
vi.mock('../../queries/useHelmRelease', () => ({ vi.mock('@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease', () => ({
useHelmRelease: () => mockUseHelmRelease(), useHelmRelease: () => mockUseHelmRelease(),
})); }));

View file

@ -1,6 +1,7 @@
import { useCurrentStateAndParams } from '@uirouter/react'; import { useCurrentStateAndParams } from '@uirouter/react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useHelmRelease } from '@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease';
import { Datatable, TableSettingsMenu } from '@@/datatables'; import { Datatable, TableSettingsMenu } from '@@/datatables';
import { import {
@ -13,8 +14,6 @@ import { Widget } from '@@/Widget';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { useHelmRelease } from '../../queries/useHelmRelease';
import { columns } from './columns'; import { columns } from './columns';
import { useResourceRows } from './useResourceRows'; import { useResourceRows } from './useResourceRows';

View file

@ -1,8 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { StatusBadgeType } from '@@/StatusBadge'; import { GenericResource } from '@/react/kubernetes/helm/types';
import { GenericResource } from '../../../types'; import { StatusBadgeType } from '@@/StatusBadge';
import { ResourceLink, ResourceRow } from './types'; import { ResourceLink, ResourceRow } from './types';

View file

@ -1,7 +1,7 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { HelmRelease } from '../../types'; import { HelmRelease } from '../../types';
import { useHelmRelease } from '../queries/useHelmRelease'; import { useHelmRelease } from '../../helmReleaseQueries/useHelmRelease';
import { DiffViewMode } from './DiffControl'; import { DiffViewMode } from './DiffControl';

View file

@ -36,14 +36,15 @@ vi.mock('@/portainer/services/notifications', () => ({
), ),
})); }));
vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({ vi.mock('../helmReleaseQueries/useUpdateHelmReleaseMutation', () => ({
useUpdateHelmReleaseMutation: vi.fn(() => ({ useUpdateHelmReleaseMutation: vi.fn(() => ({
mutateAsync: vi.fn((...args) => mockMutate(...args)), mutateAsync: vi.fn((...args) => mockMutate(...args)),
isLoading: false, isLoading: false,
})), })),
updateHelmRelease: vi.fn(() => Promise.resolve({})),
})); }));
vi.mock('../queries/useHelmRepoVersions', () => ({ vi.mock('../helmChartSourceQueries/useHelmRepoVersions', () => ({
useHelmRepoVersions: vi.fn(() => ({ useHelmRepoVersions: vi.fn(() => ({
data: [ data: [
{ Version: '1.0.0', AppVersion: '1.0.0' }, { Version: '1.0.0', AppVersion: '1.0.0' },

View file

@ -11,11 +11,11 @@ import { confirmGenericDiscard } from '@@/modals/confirm';
import { Option } from '@@/form-components/PortainerSelect'; import { Option } from '@@/form-components/PortainerSelect';
import { Chart } from '../types'; import { Chart } from '../types';
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation'; import { useUpdateHelmReleaseMutation } from '../helmReleaseQueries/useUpdateHelmReleaseMutation';
import { import {
ChartVersion, ChartVersion,
useHelmRepoVersions, useHelmRepoVersions,
} from '../queries/useHelmRepoVersions'; } from '../helmChartSourceQueries/useHelmRepoVersions';
import { HelmInstallInnerForm } from './HelmInstallInnerForm'; import { HelmInstallInnerForm } from './HelmInstallInnerForm';
import { HelmInstallFormValues } from './types'; import { HelmInstallFormValues } from './types';

View file

@ -1,5 +1,7 @@
import { Form, useFormikContext } from 'formik'; import { Form, useFormikContext } from 'formik';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
@ -7,9 +9,10 @@ import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons'; import { LoadingButton } from '@@/buttons';
import { Chart } from '../types'; import { Chart } from '../types';
import { useHelmChartValues } from '../queries/useHelmChartValues'; import { useHelmChartValues } from '../helmChartSourceQueries/useHelmChartValues';
import { HelmValuesInput } from '../components/HelmValuesInput'; import { HelmValuesInput } from '../components/HelmValuesInput';
import { ChartVersion } from '../queries/useHelmRepoVersions'; import { ChartVersion } from '../helmChartSourceQueries/useHelmRepoVersions';
import { ManifestPreviewFormSection } from '../components/ManifestPreviewFormSection';
import { HelmInstallFormValues } from './types'; import { HelmInstallFormValues } from './types';
@ -30,6 +33,8 @@ export function HelmInstallInnerForm({
isVersionsLoading, isVersionsLoading,
isRepoAvailable, isRepoAvailable,
}: Props) { }: Props) {
const environmentId = useEnvironmentId();
const [previewIsValid, setPreviewIsValid] = useState(false);
const { values, setFieldValue, isSubmitting } = const { values, setFieldValue, isSubmitting } =
useFormikContext<HelmInstallFormValues>(); useFormikContext<HelmInstallFormValues>();
@ -62,6 +67,25 @@ export function HelmInstallInnerForm({
isLatestVersionFetched isLatestVersionFetched
); );
const payload = useMemo(
() => ({
name: name || '',
namespace: namespace || '',
chart: selectedChart.name,
version: values?.version,
repo: selectedChart.repo,
values: values.values,
}),
[
name,
namespace,
selectedChart.name,
values?.version,
selectedChart.repo,
values.values,
]
);
return ( return (
<Form className="form-horizontal"> <Form className="form-horizontal">
<div className="form-group !m-0"> <div className="form-group !m-0">
@ -93,13 +117,19 @@ export function HelmInstallInnerForm({
isValuesRefLoading={chartValuesRefQuery.isInitialLoading} isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
/> />
</FormSection> </FormSection>
<ManifestPreviewFormSection
payload={payload}
onChangePreviewValidation={setPreviewIsValid}
title="Manifest preview"
environmentId={environmentId}
/>
</div> </div>
<LoadingButton <LoadingButton
className="!ml-0" className="!ml-0 mt-5"
loadingText="Installing Helm chart" loadingText="Installing Helm chart"
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={!namespace || !name || !isRepoAvailable} disabled={!namespace || !name || !isRepoAvailable || !previewIsValid}
data-cy="helm-install" data-cy="helm-install"
> >
Install Install

View file

@ -4,13 +4,13 @@ import { useCurrentUser } from '@/react/hooks/useUser';
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
import { useHelmHTTPChartList } from '../queries/useHelmChartList'; import { useHelmHTTPChartList } from '../helmChartSourceQueries/useHelmChartList';
import { Chart } from '../types'; import { Chart } from '../types';
import { import {
HelmRegistrySelect, HelmRegistrySelect,
RepoValue, RepoValue,
} from '../components/HelmRegistrySelect'; } from '../components/HelmRegistrySelect';
import { useHelmRepoOptions } from '../queries/useHelmRepositories'; import { useHelmRepoOptions } from '../helmChartSourceQueries/useHelmRepositories';
import { HelmInstallForm } from './HelmInstallForm'; import { HelmInstallForm } from './HelmInstallForm';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem'; import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';

View file

@ -0,0 +1,197 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { ManifestPreviewFormSection } from './ManifestPreviewFormSection';
// Mock the necessary hooks
const mockUseHelmDryRun = vi.fn();
const mockUseDebouncedValue = vi.fn();
vi.mock('../helmReleaseQueries/useHelmDryRun', () => ({
useHelmDryRun: (...args: unknown[]) => mockUseHelmDryRun(...args),
}));
vi.mock('@/react/hooks/useDebouncedValue', () => ({
useDebouncedValue: (value: unknown, delay: number) =>
mockUseDebouncedValue(value, delay),
}));
// Mock the CodeEditor and DiffViewer components
vi.mock('@@/CodeEditor', () => ({
CodeEditor: ({
'data-cy': dataCy,
value,
}: {
'data-cy'?: string;
value: string;
}) => (
<div data-cy={dataCy} data-testid="code-editor">
{value}
</div>
),
}));
vi.mock('@@/CodeEditor/DiffViewer', () => ({
DiffViewer: ({
'data-cy': dataCy,
originalCode,
newCode,
}: {
'data-cy'?: string;
originalCode: string;
newCode: string;
}) => (
<div data-cy={dataCy} data-testid="diff-viewer">
<div data-testid="original-code">{originalCode}</div>
<div data-testid="new-code">{newCode}</div>
</div>
),
}));
const mockOnChangePreviewValidation = vi.fn();
const defaultProps = {
payload: {
name: 'test-release',
namespace: 'test-namespace',
chart: 'test-chart',
version: '1.0.0',
repo: 'test-repo',
},
onChangePreviewValidation: mockOnChangePreviewValidation,
title: 'Manifest Preview',
environmentId: 1,
};
function renderComponent(props = {}) {
const user = new UserViewModel({ Username: 'user', Role: 1 });
const Component = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<ManifestPreviewFormSection {...defaultProps} {...props} />
)),
user
)
);
return render(<Component />);
}
describe('ManifestPreviewFormSection', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock for useDebouncedValue - returns the value as-is
mockUseDebouncedValue.mockImplementation((value) => value);
});
it('should show loading and no form section when loading', () => {
mockUseHelmDryRun.mockReturnValue({
isInitialLoading: true,
isError: false,
data: undefined,
});
renderComponent();
expect(
screen.getByText('Generating manifest preview...')
).toBeInTheDocument();
expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument();
});
it('should show error and no form section when error', () => {
mockUseHelmDryRun.mockReturnValue({
isInitialLoading: false,
isError: true,
error: { message: 'Invalid chart configuration' },
data: undefined,
});
renderComponent();
expect(
screen.getByText('Error with Helm chart configuration')
).toBeInTheDocument();
expect(screen.getByText('Invalid chart configuration')).toBeInTheDocument();
expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument();
});
it('should show single code editor when only the generated manifest is available', async () => {
const mockManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: test';
mockUseHelmDryRun.mockReturnValue({
isInitialLoading: false,
isError: false,
data: { manifest: mockManifest },
});
renderComponent();
expect(screen.getByText('Manifest Preview')).toBeInTheDocument();
// Expand the FormSection to see the content
const expandButton = screen.getByLabelText('Expand');
await userEvent.click(expandButton);
// Check that the manifest content is rendered (from the HTML, we can see it's there)
expect(
screen.getByText(/apiVersion/, { exact: false })
).toBeInTheDocument();
expect(screen.getByText(/test/, { exact: false })).toBeInTheDocument();
});
it('should show the diff when the current and generated manifest are available', async () => {
const currentManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: old';
const newManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: new';
mockUseHelmDryRun.mockReturnValue({
isInitialLoading: false,
isError: false,
data: { manifest: newManifest },
});
renderComponent({ currentManifest });
expect(screen.getByText('Manifest Preview')).toBeInTheDocument();
// Expand the FormSection to see the content
const expandButton = screen.getByLabelText('Expand');
await userEvent.click(expandButton);
// Check that both old and new manifest content is rendered
expect(screen.getByText(/old/, { exact: false })).toBeInTheDocument();
expect(screen.getByText(/new/, { exact: false })).toBeInTheDocument();
});
it('should call onChangePreviewValidation with correct validation state', () => {
mockUseHelmDryRun.mockReturnValue({
isInitialLoading: false,
isError: false,
data: { manifest: 'test' },
});
renderComponent();
expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(true);
});
it('should call onChangePreviewValidation with false when error occurs', () => {
mockUseHelmDryRun.mockReturnValue({
isInitialLoading: false,
isError: true,
error: { message: 'Error' },
data: undefined,
});
renderComponent();
expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(false);
});
});

View file

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { CodeEditor } from '@@/CodeEditor';
import { DiffViewer } from '@@/CodeEditor/DiffViewer';
import { InlineLoader } from '@@/InlineLoader';
import { Alert } from '@@/Alert';
import { TextTip } from '@@/Tip/TextTip';
import { useHelmDryRun } from '../helmReleaseQueries/useHelmDryRun';
import { UpdateHelmReleasePayload } from '../types';
type Props = {
payload: UpdateHelmReleasePayload;
onChangePreviewValidation: (isValid: boolean) => void;
currentManifest?: string; // only true on upgrade, not install
title: string;
environmentId: EnvironmentId;
};
export function ManifestPreviewFormSection({
payload,
currentManifest,
onChangePreviewValidation,
title,
environmentId,
}: Props) {
const debouncedPayload = useDebouncedValue(payload, 500);
const manifestPreviewQuery = useHelmDryRun(environmentId, debouncedPayload);
const [isFolded, setIsFolded] = useState(true);
useEffect(() => {
onChangePreviewValidation(!manifestPreviewQuery.isError);
}, [manifestPreviewQuery.isError, onChangePreviewValidation]);
if (
!debouncedPayload.name ||
!debouncedPayload.namespace ||
!debouncedPayload.chart
) {
return null;
}
// only show loading state or the error to keep the view simple (omitting the preview section because there is nothing to preview)
if (manifestPreviewQuery.isInitialLoading) {
return <InlineLoader>Generating manifest preview...</InlineLoader>;
}
if (manifestPreviewQuery.isError) {
return (
<Alert color="error" title="Error with Helm chart configuration">
{manifestPreviewQuery.error?.message ||
'Error generating manifest preview'}
</Alert>
);
}
return (
<FormSection
title={title}
isFoldable
defaultFolded={isFolded}
setIsDefaultFolded={setIsFolded}
>
<ManifestPreview
currentManifest={currentManifest}
newManifest={manifestPreviewQuery.data?.manifest ?? ''}
/>
</FormSection>
);
}
function ManifestPreview({
currentManifest,
newManifest,
}: {
currentManifest?: string;
newManifest: string;
}) {
if (!newManifest) {
return <TextTip color="blue">No manifest preview available</TextTip>;
}
if (currentManifest) {
return (
<DiffViewer
originalCode={currentManifest}
newCode={newManifest}
id="manifest-preview"
data-cy="manifest-diff-preview"
type="yaml"
/>
);
}
return (
<CodeEditor
id="manifest-preview"
value={newManifest}
data-cy="manifest-preview"
type="yaml"
readonly
showToolbar={false}
/>
);
}

View file

@ -0,0 +1,29 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { UserId } from '@/portainer/users/types';
export const queryKeys = {
// Environment-scoped Helm queries (following kubernetes pattern)
base: (environmentId: EnvironmentId) =>
['environments', environmentId, 'kubernetes', 'helm'] as const,
// User's helm repositories/registries
registries: (userId: UserId) => ['helm', 'registries', userId] as const,
// Chart repository searches (global, not environment-specific)
repositories: (chart: string, repo?: string, useCache?: boolean) =>
['helm', 'repositories', chart, repo, useCache] as const,
// Chart listings from repositories (user-specific)
charts: (userId: UserId, repository: string) =>
['helm', 'charts', userId, repository] as const,
// Chart values (global, cached by chart/version)
chartValues: (repo: string, chart: string, version: string | 'latest') =>
['helm', 'chart-values', repo, chart, version] as const,
chartVersions: (
sourceId: number | string,
chart: string,
useCache?: boolean
) => ['helm', 'registries', sourceId, chart, 'versions', useCache] as const,
};

View file

@ -6,6 +6,8 @@ import { withGlobalError } from '@/react-tools/react-query';
import { Chart, HelmChartsResponse } from '../types'; import { Chart, HelmChartsResponse } from '../types';
import { queryKeys } from './query-keys';
/** /**
* React hook to fetch helm charts from the provided HTTP repository. * React hook to fetch helm charts from the provided HTTP repository.
* Charts are loaded from the specified repository URL. * Charts are loaded from the specified repository URL.
@ -21,7 +23,7 @@ export function useHelmHTTPChartList(
enabled: boolean enabled: boolean
) { ) {
return useQuery({ return useQuery({
queryKey: [userId, repository, 'helm-charts'], queryKey: queryKeys.charts(userId, repository),
queryFn: () => getChartsFromRepo(repository), queryFn: () => getChartsFromRepo(repository),
enabled: !!userId && !!repository && enabled, enabled: !!userId && !!repository && enabled,
// one request takes a long time, so fail early to get feedback to the user faster // one request takes a long time, so fail early to get feedback to the user faster

View file

@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import { queryKeys } from './query-keys';
type Params = { type Params = {
/** The name of the chart to get the values for */ /** The name of the chart to get the values for */
chart: string; chart: string;
@ -12,6 +14,26 @@ type Params = {
version?: string; version?: string;
}; };
export function useHelmChartValues(params: Params, isLatestVersion = false) {
const hasValidRepoUrl = !!params.repo;
return useQuery({
queryKey: queryKeys.chartValues(
params.repo,
params.chart,
// if the latest version is fetched, use the latest version key to cache the latest version
isLatestVersion ? 'latest' : params.version || 'latest'
),
queryFn: () => getHelmChartValues(params),
enabled: !!params.chart && hasValidRepoUrl,
select: (data) => ({
values: data,
}),
retry: 1,
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
...withGlobalError('Unable to get Helm chart values'),
});
}
async function getHelmChartValues(params: Params) { async function getHelmChartValues(params: Params) {
try { try {
const response = await axios.get<string>(`/templates/helm/values`, { const response = await axios.get<string>(`/templates/helm/values`, {
@ -22,24 +44,3 @@ async function getHelmChartValues(params: Params) {
throw parseAxiosError(err, 'Unable to get Helm chart values'); throw parseAxiosError(err, 'Unable to get Helm chart values');
} }
} }
export function useHelmChartValues(params: Params, isLatestVersion = false) {
const hasValidRepoUrl = !!params.repo;
return useQuery({
queryKey: [
'helm-chart-values',
params.repo,
params.chart,
// if the latest version is fetched, use the latest version key to cache the latest version
isLatestVersion ? 'latest' : params.version,
],
queryFn: () => getHelmChartValues(params),
enabled: !!params.chart && hasValidRepoUrl,
select: (data) => ({
values: data,
}),
retry: 1,
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
...withGlobalError('Unable to get Helm chart values'),
});
}

View file

@ -5,6 +5,8 @@ import { compact, flatMap } from 'lodash';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { queryKeys } from './query-keys';
interface HelmSearch { interface HelmSearch {
entries: Entries; entries: Entries;
} }
@ -44,9 +46,9 @@ export function useHelmRepoVersions(
queries: useMemo( queries: useMemo(
() => () =>
repoSources.map(({ repo }) => ({ repoSources.map(({ repo }) => ({
queryKey: ['helm', 'repositories', chart, repo, useCache], queryKey: queryKeys.chartVersions(repo || '', chart),
queryFn: () => getSearchHelmRepo({ repo, chart, useCache }), queryFn: () => getSearchHelmRepo({ repo, chart, useCache }),
enabled: !!chart && repoSources.length > 0, enabled: !!chart && !!repo,
staleTime, staleTime,
...withGlobalError(`Unable to retrieve versions from ${repo}`), ...withGlobalError(`Unable to retrieve versions from ${repo}`),
})), })),

View file

@ -10,17 +10,19 @@ import { Option } from '@/react/components/form-components/PortainerSelect';
import { HelmRegistriesResponse } from '../types'; import { HelmRegistriesResponse } from '../types';
import { RepoValue } from '../components/HelmRegistrySelect'; import { RepoValue } from '../components/HelmRegistrySelect';
import { queryKeys } from './query-keys';
/** /**
* Hook to fetch all Helm registries for the current user * Hook to fetch all Helm registries for the current user
*/ */
export function useUserHelmRepositories<T = string[]>({ export function useUserHelmRepositories<T = string[]>({
select, select,
}: { }: {
select?: (registries: string[]) => T; select?: (registries: HelmRegistriesResponse) => T;
} = {}) { } = {}) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
return useQuery( return useQuery(
['helm', 'registries'], queryKeys.registries(user.Id),
async () => getUserHelmRepositories(user.Id), async () => getUserHelmRepositories(user.Id),
{ {
enabled: !!user.Id, enabled: !!user.Id,
@ -33,7 +35,8 @@ export function useUserHelmRepositories<T = string[]>({
export function useHelmRepoOptions() { export function useHelmRepoOptions() {
return useUserHelmRepositories({ return useUserHelmRepositories({
select: (registries) => { select: (registries) => {
const repoOptions = registries const registryArray = flattenHelmRegistries(registries);
const repoOptions = registryArray
.map<Option<RepoValue>>((registry) => ({ .map<Option<RepoValue>>((registry) => ({
label: registry, label: registry,
value: { value: {
@ -72,13 +75,18 @@ async function getUserHelmRepositories(userId: UserId) {
const { data } = await axios.get<HelmRegistriesResponse>( const { data } = await axios.get<HelmRegistriesResponse>(
`users/${userId}/helm/repositories` `users/${userId}/helm/repositories`
); );
// compact will remove the global repository if it's empty return data;
const repos = compact([
data.GlobalRepository.toLowerCase(),
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
]);
return [...new Set(repos)];
} catch (err) { } catch (err) {
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user'); throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
} }
} }
/** get the unique global and user registries in one array */
export function flattenHelmRegistries(registries: HelmRegistriesResponse) {
// compact will remove the global repository if it's empty
const repos = compact([
registries.GlobalRepository.toLowerCase(),
...registries.UserRepositories.map((repo) => repo.URL.toLowerCase()),
]);
return [...new Set(repos)];
}

View file

@ -0,0 +1,47 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
import { UpdateHelmReleasePayload } from '../types';
export const queryKeys = {
// Environment-scoped Helm queries (following kubernetes pattern)
base: (environmentId: EnvironmentId) =>
[
...environmentQueryKeys.item(environmentId),
'kubernetes',
'helm',
] as const,
// Helm releases (environment-specific)
releases: (environmentId: EnvironmentId) =>
[...queryKeys.base(environmentId), 'releases'] as const,
release: (
environmentId: EnvironmentId,
namespace: string,
name: string,
revision?: number,
showResources?: boolean
) =>
[
...queryKeys.releases(environmentId),
namespace,
name,
revision,
showResources,
] as const,
releaseHistory: (
environmentId: EnvironmentId,
namespace: string,
name: string
) =>
[...queryKeys.release(environmentId, namespace, name), 'history'] as const,
// Environment-specific install operations
installDryRun: (
environmentId: EnvironmentId,
payload: UpdateHelmReleasePayload
) =>
[...queryKeys.base(environmentId), 'install', 'dry-run', payload] as const,
};

View file

@ -0,0 +1,38 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import PortainerError from '@/portainer/error';
import { HelmRelease, UpdateHelmReleasePayload } from '../types';
import { updateHelmRelease } from './useUpdateHelmReleaseMutation';
import { queryKeys } from './query-keys';
export function useHelmDryRun(
environmentId: EnvironmentId,
payload: UpdateHelmReleasePayload
): UseQueryResult<HelmRelease, PortainerError> {
return useQuery({
queryKey: queryKeys.installDryRun(environmentId, payload),
queryFn: () =>
// use updateHelmRelease as if it were a get request with dryRun. The payload is debounced to prevent too many requests.
updateHelmRelease(
environmentId,
payload,
{ dryRun: true },
{
errorMessage: 'Unable to get Helm manifest preview',
}
),
// don't display error toast, handle it within the component
enabled:
!!payload.repo &&
!!payload.chart &&
!!payload.name &&
!!payload.namespace &&
!!payload.version,
retry: false,
refetchOnWindowFocus: false,
staleTime: 1000 * 60, // small 1 minute stale time to reduce the number of requests
});
}

View file

@ -4,7 +4,9 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { HelmRelease } from '../../types'; import { HelmRelease } from '../types';
import { queryKeys } from './query-keys';
export function useHelmHistory( export function useHelmHistory(
environmentId: EnvironmentId, environmentId: EnvironmentId,
@ -12,7 +14,7 @@ export function useHelmHistory(
namespace: string namespace: string
) { ) {
return useQuery( return useQuery(
[environmentId, 'helm', 'releases', namespace, name, 'history'], queryKeys.releaseHistory(environmentId, namespace, name),
() => getHelmHistory(environmentId, name, namespace), () => getHelmHistory(environmentId, name, namespace),
{ {
enabled: !!environmentId && !!name && !!namespace, enabled: !!environmentId && !!name && !!namespace,

View file

@ -4,7 +4,9 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { HelmRelease } from '../../types'; import { HelmRelease } from '../types';
import { queryKeys } from './query-keys';
type Options<T> = { type Options<T> = {
select?: (data: HelmRelease) => T; select?: (data: HelmRelease) => T;
@ -27,15 +29,7 @@ export function useHelmRelease<T = HelmRelease>(
const { select, showResources, refetchInterval, revision, staleTime } = const { select, showResources, refetchInterval, revision, staleTime } =
options; options;
return useQuery( return useQuery(
[ queryKeys.release(environmentId, namespace, name, revision, showResources),
environmentId,
'helm',
'releases',
namespace,
name,
revision,
showResources,
],
() => () =>
getHelmRelease(environmentId, name, { getHelmRelease(environmentId, name, {
namespace, namespace,

View file

@ -7,7 +7,9 @@ import {
withGlobalError, withGlobalError,
} from '@/react-tools/react-query'; } from '@/react-tools/react-query';
import axios from '@/portainer/services/axios'; import axios from '@/portainer/services/axios';
import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys'; import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
import { queryKeys } from './query-keys';
/** /**
* Parameters for helm rollback operation * Parameters for helm rollback operation
@ -54,8 +56,8 @@ export function useHelmRollbackMutation(environmentId: EnvironmentId) {
rollbackRelease({ releaseName, params, environmentId }), rollbackRelease({ releaseName, params, environmentId }),
...withGlobalError('Unable to rollback Helm release'), ...withGlobalError('Unable to rollback Helm release'),
...withInvalidate(queryClient, [ ...withInvalidate(queryClient, [
[environmentId, 'helm', 'releases'], queryKeys.releases(environmentId),
queryKeys.applications(environmentId), applicationsQueryKeys.applications(environmentId),
]), ]),
}); });
} }

View file

@ -5,6 +5,8 @@ import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys'; import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
export function useUninstallHelmAppMutation(environmentId: EnvironmentId) { export function useUninstallHelmAppMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@ -16,6 +18,7 @@ export function useUninstallHelmAppMutation(environmentId: EnvironmentId) {
namespace?: string; namespace?: string;
}) => uninstallHelmApplication(environmentId, releaseName, namespace), }) => uninstallHelmApplication(environmentId, releaseName, namespace),
...withInvalidate(queryClient, [ ...withInvalidate(queryClient, [
queryKeys.releases(environmentId),
applicationsQueryKeys.applications(environmentId), applicationsQueryKeys.applications(environmentId),
]), ]),
...withGlobalError('Unable to uninstall helm application'), ...withGlobalError('Unable to uninstall helm application'),

View file

@ -7,30 +7,48 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { HelmRelease, UpdateHelmReleasePayload } from '../types'; import { HelmRelease, UpdateHelmReleasePayload } from '../types';
import { queryKeys } from './query-keys';
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) { export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: UpdateHelmReleasePayload) => mutationFn: (payload: UpdateHelmReleasePayload) =>
updateHelmRelease(environmentId, payload), updateHelmRelease(environmentId, payload),
...withInvalidate(queryClient, [ ...withInvalidate(queryClient, [
[environmentId, 'helm', 'releases'], queryKeys.releases(environmentId),
applicationsQueryKeys.applications(environmentId), applicationsQueryKeys.applications(environmentId),
]), ]),
...withGlobalError('Unable to uninstall helm application'), ...withGlobalError('Unable to update Helm release'),
}); });
} }
async function updateHelmRelease( type UpdateHelmReleaseParams = {
dryRun?: boolean;
};
type UpdateHelmReleaseOptions = {
errorMessage?: string;
};
export async function updateHelmRelease(
environmentId: EnvironmentId, environmentId: EnvironmentId,
payload: UpdateHelmReleasePayload payload: UpdateHelmReleasePayload,
params: UpdateHelmReleaseParams = {},
options: UpdateHelmReleaseOptions = {}
) { ) {
try { try {
const { data } = await axios.post<HelmRelease>( const { data } = await axios.post<HelmRelease>(
`endpoints/${environmentId}/kubernetes/helm`, `endpoints/${environmentId}/kubernetes/helm`,
payload payload,
{
params,
}
); );
return data; return data;
} catch (err) { } catch (err) {
throw parseAxiosError(err, 'Unable to update helm release'); throw parseAxiosError(
err,
options.errorMessage ?? 'Unable to update helm release'
);
} }
} }

View file

@ -4,6 +4,8 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { success as notifySuccess } from '@/portainer/services/notifications'; import { success as notifySuccess } from '@/portainer/services/notifications';
import { withError } from '@/react-tools/react-query'; import { withError } from '@/react-tools/react-query';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { queryKeys } from '@/react/kubernetes/helm/helmChartSourceQueries/query-keys';
import { useCurrentUser } from '@/react/hooks/useUser';
import { import {
CreateHelmRepositoryPayload, CreateHelmRepositoryPayload,
@ -52,11 +54,12 @@ export async function deleteHelmRepositories(repos: HelmRepository[]) {
export function useDeleteHelmRepositoryMutation() { export function useDeleteHelmRepositoryMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useCurrentUser();
return useMutation(deleteHelmRepository, { return useMutation(deleteHelmRepository, {
onSuccess: (_, helmRepository) => { onSuccess: (_, helmRepository) => {
notifySuccess('Helm repository deleted successfully', helmRepository.URL); notifySuccess('Helm repository deleted successfully', helmRepository.URL);
return queryClient.invalidateQueries(['helmrepositories']); return queryClient.invalidateQueries(queryKeys.registries(user.Id));
}, },
...withError('Unable to delete Helm repository'), ...withError('Unable to delete Helm repository'),
}); });
@ -64,6 +67,7 @@ export function useDeleteHelmRepositoryMutation() {
export function useDeleteHelmRepositoriesMutation() { export function useDeleteHelmRepositoriesMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useCurrentUser();
return useMutation(deleteHelmRepositories, { return useMutation(deleteHelmRepositories, {
onSuccess: () => { onSuccess: () => {
@ -75,26 +79,31 @@ export function useDeleteHelmRepositoriesMutation() {
'repositories' 'repositories'
)} deleted successfully` )} deleted successfully`
); );
return queryClient.invalidateQueries(['helmrepositories']); return queryClient.invalidateQueries(queryKeys.registries(user.Id));
}, },
...withError('Unable to delete Helm repositories'), ...withError('Unable to delete Helm repositories'),
}); });
} }
export function useHelmRepositories(userId: number) { export function useHelmRepositories(userId: number) {
return useQuery(['helmrepositories'], () => getHelmRepositories(userId), { return useQuery(
staleTime: 20, queryKeys.registries(userId),
...withError('Unable to retrieve Helm repositories'), () => getHelmRepositories(userId),
}); {
staleTime: 20,
...withError('Unable to retrieve Helm repositories'),
}
);
} }
export function useCreateHelmRepositoryMutation() { export function useCreateHelmRepositoryMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useCurrentUser();
return useMutation(createHelmRepository, { return useMutation(createHelmRepository, {
onSuccess: (_, payload) => { onSuccess: (_, payload) => {
notifySuccess('Helm repository created successfully', payload.URL); notifySuccess('Helm repository created successfully', payload.URL);
return queryClient.invalidateQueries(['helmrepositories']); return queryClient.invalidateQueries(queryKeys.registries(user.Id));
}, },
...withError('Unable to create Helm repository'), ...withError('Unable to create Helm repository'),
}); });

View file

@ -1,7 +1,36 @@
export function mockCodeMirror() { export function mockCodeMirror() {
vi.mock('@uiw/react-codemirror', () => ({ vi.mock('@uiw/react-codemirror', () => ({
__esModule: true, __esModule: true,
default: () => <div />, default: ({
value,
onChange,
readOnly,
placeholder,
height,
className,
id,
'data-cy': dataCy,
}: {
value?: string;
onChange?: (value: string) => void;
readOnly?: boolean;
placeholder?: string;
height?: string;
className?: string;
id?: string;
'data-cy'?: string;
}) => (
<textarea
value={value}
onChange={(e) => onChange?.(e.target.value)}
readOnly={readOnly}
placeholder={placeholder}
style={height ? { height } : undefined}
className={className}
id={id}
data-cy={dataCy}
/>
),
oneDarkHighlightStyle: {}, oneDarkHighlightStyle: {},
keymap: { keymap: {
of: () => ({}), of: () => ({}),

View file

@ -1,5 +1,10 @@
import 'vitest-dom/extend-expect'; import 'vitest-dom/extend-expect';
import { mockCodeMirror } from './mock-codemirror';
// Initialize CodeMirror module mocks
mockCodeMirror();
// Mock Range APIs that CodeMirror needs but JSDOM doesn't provide // Mock Range APIs that CodeMirror needs but JSDOM doesn't provide
Range.prototype.getBoundingClientRect = () => ({ Range.prototype.getBoundingClientRect = () => ({
bottom: 0, bottom: 0,

View file

@ -17,6 +17,7 @@ type InstallOptions struct {
ValuesFile string ValuesFile string
PostRenderer string PostRenderer string
Atomic bool Atomic bool
DryRun bool
Timeout time.Duration Timeout time.Duration
KubernetesClusterAccess *KubernetesClusterAccess KubernetesClusterAccess *KubernetesClusterAccess

View file

@ -113,6 +113,10 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
Str("namespace", installOpts.Namespace). Str("namespace", installOpts.Namespace).
Err(err). Err(err).
Msg("Failed to install helm chart for helm release installation") Msg("Failed to install helm chart for helm release installation")
if installOpts.DryRun {
// remove installation wording for dry run. The inner error has enough context.
return nil, errors.Wrap(err, "dry-run failed")
}
return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation") return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation")
} }
@ -142,6 +146,7 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I
installClient.Wait = installOpts.Wait installClient.Wait = installOpts.Wait
installClient.Timeout = installOpts.Timeout installClient.Timeout = installOpts.Timeout
installClient.Version = installOpts.Version installClient.Version = installOpts.Version
installClient.DryRun = installOpts.DryRun
err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry) err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation") return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation")

View file

@ -165,6 +165,7 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
upgradeClient.Atomic = upgradeOpts.Atomic upgradeClient.Atomic = upgradeOpts.Atomic
upgradeClient.Wait = upgradeOpts.Wait upgradeClient.Wait = upgradeOpts.Wait
upgradeClient.Version = upgradeOpts.Version upgradeClient.Version = upgradeOpts.Version
upgradeClient.DryRun = upgradeOpts.DryRun
err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry) err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade") return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade")