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:
parent
a4cff13531
commit
60bc04bc33
41 changed files with 763 additions and 157 deletions
|
@ -46,18 +46,24 @@ var errChartNameInvalid = errors.New("invalid chart name. " +
|
|||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param payload body installChartPayload true "Chart details"
|
||||
// @param dryRun query bool false "Dry run"
|
||||
// @success 201 {object} release.Release "Created"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 404 "Environment(Endpoint) or ServiceAccount not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints/{id}/kubernetes/helm [post]
|
||||
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
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid Helm install payload", err)
|
||||
}
|
||||
|
||||
release, err := handler.installChart(r, payload)
|
||||
release, err := handler.installChart(r, payload, dryRun)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to install a chart", err)
|
||||
}
|
||||
|
@ -94,7 +100,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
|
|||
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)
|
||||
if httperr != nil {
|
||||
return nil, httperr.Err
|
||||
|
@ -107,6 +113,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
|||
Namespace: p.Namespace,
|
||||
Repo: p.Repo,
|
||||
Atomic: p.Atomic,
|
||||
DryRun: dryRun,
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
}
|
||||
|
||||
|
@ -134,14 +141,15 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if !installOpts.DryRun {
|
||||
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
|
|
@ -122,7 +122,7 @@ test('should apply custom height', async () => {
|
|||
<CodeEditor {...defaultProps} height={customHeight} />
|
||||
);
|
||||
|
||||
const editor = (await findByRole('textbox')).parentElement?.parentElement;
|
||||
const editor = await findByRole('textbox');
|
||||
expect(editor).toHaveStyle({ height: customHeight });
|
||||
});
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ function yamlLanguage(schema?: JSONSchema7) {
|
|||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
|
||||
lineNumbers(),
|
||||
lintGutter(),
|
||||
!!schema && lintGutter(),
|
||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||
// only show completions when a schema is provided
|
||||
!!schema &&
|
||||
|
|
|
@ -12,6 +12,7 @@ interface Props {
|
|||
titleClassName?: string;
|
||||
className?: string;
|
||||
htmlFor?: string;
|
||||
setIsDefaultFolded?: (isDefaultFolded: boolean) => void;
|
||||
}
|
||||
|
||||
export function FormSection({
|
||||
|
@ -23,6 +24,7 @@ export function FormSection({
|
|||
titleClassName,
|
||||
className,
|
||||
htmlFor = '',
|
||||
setIsDefaultFolded,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
|
||||
const id = `foldingButton${title}`;
|
||||
|
@ -39,7 +41,10 @@ export function FormSection({
|
|||
isExpanded={isExpanded}
|
||||
data-cy={id}
|
||||
id={id}
|
||||
onClick={() => setIsExpanded((isExpanded) => !isExpanded)}
|
||||
onClick={() => {
|
||||
setIsExpanded((isExpanded) => !isExpanded);
|
||||
setIsDefaultFolded?.(isExpanded);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import { getAllSettledItems } from '@/portainer/helpers/promise-utils';
|
|||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
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 { ApplicationRowData } from '../ListView/ApplicationsDatatable/types';
|
||||
|
|
|
@ -9,7 +9,7 @@ import { buildConfirmButton } from '@@/modals/utils';
|
|||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
|
||||
import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
|
||||
import { useHelmRollbackMutation } from '../../helmReleaseQueries/useHelmRollbackMutation';
|
||||
|
||||
type Props = {
|
||||
latestRevision: number;
|
||||
|
|
|
@ -5,7 +5,7 @@ import { notifySuccess } from '@/portainer/services/notifications';
|
|||
|
||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||
|
||||
import { useUninstallHelmAppMutation } from '../queries/useUninstallHelmAppMutation';
|
||||
import { useUninstallHelmAppMutation } from '../../helmReleaseQueries/useUninstallHelmAppMutation';
|
||||
|
||||
export function UninstallButton({
|
||||
environmentId,
|
||||
|
|
|
@ -9,7 +9,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
|||
import {
|
||||
useHelmRepoVersions,
|
||||
ChartVersion,
|
||||
} from '../../queries/useHelmRepoVersions';
|
||||
} from '../../helmChartSourceQueries/useHelmRepoVersions';
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||
|
@ -25,32 +25,56 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||
notifySuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../queries/useHelmRepositories', () => ({
|
||||
vi.mock('../../helmChartSourceQueries/useHelmRepositories', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'../../helmChartSourceQueries/useHelmRepositories'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useUserHelmRepositories: vi.fn(() => ({
|
||||
data: ['repo1', 'repo2'],
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../queries/useHelmRepoVersions', () => ({
|
||||
vi.mock('../../helmChartSourceQueries/useHelmRepoVersions', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'../../helmChartSourceQueries/useHelmRepoVersions'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useHelmRepoVersions: vi.fn(),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the useHelmRelease hook
|
||||
vi.mock('../queries/useHelmRelease', () => ({
|
||||
vi.mock('../../helmReleaseQueries/useHelmRelease', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'../../helmReleaseQueries/useHelmRelease'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useHelmRelease: vi.fn(() => ({
|
||||
data: '1.0.0',
|
||||
})),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the useUpdateHelmReleaseMutation hook
|
||||
vi.mock('../../queries/useUpdateHelmReleaseMutation', () => ({
|
||||
vi.mock('../../helmReleaseQueries/useUpdateHelmReleaseMutation', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'../../helmReleaseQueries/useUpdateHelmReleaseMutation'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useUpdateHelmReleaseMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
function renderButton(props = {}) {
|
||||
const defaultProps = {
|
||||
|
@ -157,12 +181,14 @@ describe('UpgradeButton', () => {
|
|||
metadata: {
|
||||
name: 'test-chart',
|
||||
version: '1.0.0',
|
||||
appVersion: '1.0.0',
|
||||
},
|
||||
},
|
||||
values: {
|
||||
userSuppliedValues: '{}',
|
||||
},
|
||||
manifest: '',
|
||||
namespace: 'default',
|
||||
} as HelmRelease;
|
||||
|
||||
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||
|
@ -193,7 +219,9 @@ describe('UpgradeButton', () => {
|
|||
expect.arrayContaining([
|
||||
{ Version: '1.0.0', Repo: 'stable' },
|
||||
{ Version: '1.1.0', Repo: 'stable' },
|
||||
])
|
||||
]),
|
||||
'', // releaseManifest
|
||||
1 // environmentId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,10 +12,13 @@ import { Tooltip } from '@@/Tip/Tooltip';
|
|||
import { Link } from '@@/Link';
|
||||
|
||||
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
|
||||
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
|
||||
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions';
|
||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||
import { useUserHelmRepositories } from '../../queries/useHelmRepositories';
|
||||
import { useUpdateHelmReleaseMutation } from '../../helmReleaseQueries/useUpdateHelmReleaseMutation';
|
||||
import { useHelmRepoVersions } from '../../helmChartSourceQueries/useHelmRepoVersions';
|
||||
import { useHelmRelease } from '../../helmReleaseQueries/useHelmRelease';
|
||||
import {
|
||||
flattenHelmRegistries,
|
||||
useUserHelmRepositories,
|
||||
} from '../../helmChartSourceQueries/useHelmRepositories';
|
||||
|
||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||
|
||||
|
@ -36,7 +39,9 @@ export function UpgradeButton({
|
|||
const [useCache, setUseCache] = useState(true);
|
||||
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||
|
||||
const userRepositoriesQuery = useUserHelmRepositories();
|
||||
const userRepositoriesQuery = useUserHelmRepositories({
|
||||
select: flattenHelmRegistries,
|
||||
});
|
||||
const helmRepoVersionsQuery = useHelmRepoVersions(
|
||||
release?.chart.metadata?.name || '',
|
||||
60 * 60 * 1000, // 1 hour
|
||||
|
@ -164,7 +169,9 @@ export function UpgradeButton({
|
|||
async function handleUpgrade() {
|
||||
const submittedUpgradeValues = await openUpgradeHelmModal(
|
||||
editableHelmRelease,
|
||||
filteredVersions
|
||||
filteredVersions,
|
||||
release?.manifest || '',
|
||||
environmentId
|
||||
);
|
||||
|
||||
if (submittedUpgradeValues) {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
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 { Button } from '@@/buttons';
|
||||
|
@ -15,26 +16,32 @@ import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
|||
|
||||
import { UpdateHelmReleasePayload } from '../../types';
|
||||
import { HelmValuesInput } from '../../components/HelmValuesInput';
|
||||
import { useHelmChartValues } from '../../queries/useHelmChartValues';
|
||||
import { useHelmChartValues } from '../../helmChartSourceQueries/useHelmChartValues';
|
||||
import { ManifestPreviewFormSection } from '../../components/ManifestPreviewFormSection';
|
||||
|
||||
interface Props {
|
||||
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
|
||||
payload: UpdateHelmReleasePayload;
|
||||
helmReleaseInitialValues: UpdateHelmReleasePayload;
|
||||
releaseManifest: string;
|
||||
versions: ChartVersion[];
|
||||
chartName: string;
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
export function UpgradeHelmModal({
|
||||
payload,
|
||||
helmReleaseInitialValues,
|
||||
releaseManifest,
|
||||
versions,
|
||||
onSubmit,
|
||||
chartName,
|
||||
environmentId,
|
||||
}: Props) {
|
||||
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 =
|
||||
version.AppVersion === payload.appVersion &&
|
||||
version.Version === payload.version;
|
||||
version.AppVersion === helmReleaseInitialValues.appVersion &&
|
||||
version.Version === helmReleaseInitialValues.version;
|
||||
|
||||
const label = `${repo}@${version.Version}${
|
||||
isCurrentVersion ? ' (current)' : ''
|
||||
|
@ -50,13 +57,16 @@ export function UpgradeHelmModal({
|
|||
const defaultVersion =
|
||||
versionOptions.find(
|
||||
(v) =>
|
||||
v.value.AppVersion === payload.appVersion &&
|
||||
v.value.Version === payload.version &&
|
||||
v.value.Repo === payload.repo
|
||||
v.value.AppVersion === helmReleaseInitialValues.appVersion &&
|
||||
v.value.Version === helmReleaseInitialValues.version &&
|
||||
v.value.Repo === helmReleaseInitialValues.repo
|
||||
)?.value || versionOptions[0]?.value;
|
||||
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 [previewIsValid, setPreviewIsValid] = useState<boolean>(false);
|
||||
|
||||
const chartValuesRefQuery = useHelmChartValues({
|
||||
chart: chartName,
|
||||
|
@ -64,6 +74,19 @@ export function UpgradeHelmModal({
|
|||
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 (
|
||||
<Modal
|
||||
onDismiss={() => onSubmit()}
|
||||
|
@ -84,7 +107,7 @@ export function UpgradeHelmModal({
|
|||
>
|
||||
<Input
|
||||
id="release-name-input"
|
||||
value={payload.name}
|
||||
value={helmReleaseInitialValues.name}
|
||||
readOnly
|
||||
disabled
|
||||
data-cy="helm-release-name-input"
|
||||
|
@ -97,7 +120,7 @@ export function UpgradeHelmModal({
|
|||
>
|
||||
<Input
|
||||
id="namespace-input"
|
||||
value={payload.namespace}
|
||||
value={helmReleaseInitialValues.namespace}
|
||||
readOnly
|
||||
disabled
|
||||
data-cy="helm-namespace-input"
|
||||
|
@ -134,6 +157,15 @@ export function UpgradeHelmModal({
|
|||
valuesRef={chartValuesRefQuery.data?.values ?? ''}
|
||||
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
|
||||
/>
|
||||
<div className="mb-10">
|
||||
<ManifestPreviewFormSection
|
||||
payload={submitPayload}
|
||||
onChangePreviewValidation={setPreviewIsValid}
|
||||
title="Manifest changes"
|
||||
currentManifest={releaseManifest}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</div>
|
||||
|
@ -149,17 +181,8 @@ export function UpgradeHelmModal({
|
|||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
name: payload.name,
|
||||
values: userValues,
|
||||
namespace: payload.namespace,
|
||||
chart: payload.chart,
|
||||
repo: version.Repo,
|
||||
version: version.Version,
|
||||
atomic,
|
||||
})
|
||||
}
|
||||
onClick={() => onSubmit(submitPayload)}
|
||||
disabled={!previewIsValid}
|
||||
color="primary"
|
||||
key="update-button"
|
||||
size="medium"
|
||||
|
@ -174,12 +197,16 @@ export function UpgradeHelmModal({
|
|||
}
|
||||
|
||||
export async function openUpgradeHelmModal(
|
||||
payload: UpdateHelmReleasePayload,
|
||||
versions: ChartVersion[]
|
||||
helmReleaseInitialValues: UpdateHelmReleasePayload,
|
||||
versions: ChartVersion[],
|
||||
releaseManifest: string,
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
|
||||
payload,
|
||||
helmReleaseInitialValues,
|
||||
versions,
|
||||
chartName: payload.chart,
|
||||
chartName: helmReleaseInitialValues.chart,
|
||||
releaseManifest,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -12,14 +12,14 @@ import { Alert } from '@@/Alert';
|
|||
|
||||
import { HelmRelease } from '../types';
|
||||
import { useIsSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace';
|
||||
import { useHelmRelease } from '../helmReleaseQueries/useHelmRelease';
|
||||
import { useHelmHistory } from '../helmReleaseQueries/useHelmHistory';
|
||||
|
||||
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();
|
||||
|
|
|
@ -11,7 +11,7 @@ import { Badge } from '@@/Badge';
|
|||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
import { useHelmHistory } from '../queries/useHelmHistory';
|
||||
import { useHelmHistory } from '../../helmReleaseQueries/useHelmHistory';
|
||||
|
||||
import { ManifestDetails } from './ManifestDetails';
|
||||
import { NotesDetails } from './NotesDetails';
|
||||
|
|
|
@ -3,8 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
|
||||
import { GenericResource } from '../../../types';
|
||||
import { GenericResource } from '@/react/kubernetes/helm/types';
|
||||
|
||||
import { ResourcesTable } from './ResourcesTable';
|
||||
|
||||
|
@ -22,7 +21,7 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
|||
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||
}));
|
||||
|
||||
vi.mock('../../queries/useHelmRelease', () => ({
|
||||
vi.mock('@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease', () => ({
|
||||
useHelmRelease: () => mockUseHelmRelease(),
|
||||
}));
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useHelmRelease } from '@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import {
|
||||
|
@ -13,8 +14,6 @@ import { Widget } from '@@/Widget';
|
|||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { useHelmRelease } from '../../queries/useHelmRelease';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { useResourceRows } from './useResourceRows';
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
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';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||
import { useHelmRelease } from '../../helmReleaseQueries/useHelmRelease';
|
||||
|
||||
import { DiffViewMode } from './DiffControl';
|
||||
|
||||
|
|
|
@ -36,14 +36,15 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({
|
||||
vi.mock('../helmReleaseQueries/useUpdateHelmReleaseMutation', () => ({
|
||||
useUpdateHelmReleaseMutation: vi.fn(() => ({
|
||||
mutateAsync: vi.fn((...args) => mockMutate(...args)),
|
||||
isLoading: false,
|
||||
})),
|
||||
updateHelmRelease: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock('../queries/useHelmRepoVersions', () => ({
|
||||
vi.mock('../helmChartSourceQueries/useHelmRepoVersions', () => ({
|
||||
useHelmRepoVersions: vi.fn(() => ({
|
||||
data: [
|
||||
{ Version: '1.0.0', AppVersion: '1.0.0' },
|
||||
|
|
|
@ -11,11 +11,11 @@ import { confirmGenericDiscard } from '@@/modals/confirm';
|
|||
import { Option } from '@@/form-components/PortainerSelect';
|
||||
|
||||
import { Chart } from '../types';
|
||||
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
|
||||
import { useUpdateHelmReleaseMutation } from '../helmReleaseQueries/useUpdateHelmReleaseMutation';
|
||||
import {
|
||||
ChartVersion,
|
||||
useHelmRepoVersions,
|
||||
} from '../queries/useHelmRepoVersions';
|
||||
} from '../helmChartSourceQueries/useHelmRepoVersions';
|
||||
|
||||
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
|
||||
import { HelmInstallFormValues } from './types';
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
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 { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
|
@ -7,9 +9,10 @@ import { FormSection } from '@@/form-components/FormSection';
|
|||
import { LoadingButton } from '@@/buttons';
|
||||
|
||||
import { Chart } from '../types';
|
||||
import { useHelmChartValues } from '../queries/useHelmChartValues';
|
||||
import { useHelmChartValues } from '../helmChartSourceQueries/useHelmChartValues';
|
||||
import { HelmValuesInput } from '../components/HelmValuesInput';
|
||||
import { ChartVersion } from '../queries/useHelmRepoVersions';
|
||||
import { ChartVersion } from '../helmChartSourceQueries/useHelmRepoVersions';
|
||||
import { ManifestPreviewFormSection } from '../components/ManifestPreviewFormSection';
|
||||
|
||||
import { HelmInstallFormValues } from './types';
|
||||
|
||||
|
@ -30,6 +33,8 @@ export function HelmInstallInnerForm({
|
|||
isVersionsLoading,
|
||||
isRepoAvailable,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const [previewIsValid, setPreviewIsValid] = useState(false);
|
||||
const { values, setFieldValue, isSubmitting } =
|
||||
useFormikContext<HelmInstallFormValues>();
|
||||
|
||||
|
@ -62,6 +67,25 @@ export function HelmInstallInnerForm({
|
|||
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 (
|
||||
<Form className="form-horizontal">
|
||||
<div className="form-group !m-0">
|
||||
|
@ -93,13 +117,19 @@ export function HelmInstallInnerForm({
|
|||
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
|
||||
/>
|
||||
</FormSection>
|
||||
<ManifestPreviewFormSection
|
||||
payload={payload}
|
||||
onChangePreviewValidation={setPreviewIsValid}
|
||||
title="Manifest preview"
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LoadingButton
|
||||
className="!ml-0"
|
||||
className="!ml-0 mt-5"
|
||||
loadingText="Installing Helm chart"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!namespace || !name || !isRepoAvailable}
|
||||
disabled={!namespace || !name || !isRepoAvailable || !previewIsValid}
|
||||
data-cy="helm-install"
|
||||
>
|
||||
Install
|
||||
|
|
|
@ -4,13 +4,13 @@ import { useCurrentUser } from '@/react/hooks/useUser';
|
|||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
import { useHelmHTTPChartList } from '../queries/useHelmChartList';
|
||||
import { useHelmHTTPChartList } from '../helmChartSourceQueries/useHelmChartList';
|
||||
import { Chart } from '../types';
|
||||
import {
|
||||
HelmRegistrySelect,
|
||||
RepoValue,
|
||||
} from '../components/HelmRegistrySelect';
|
||||
import { useHelmRepoOptions } from '../queries/useHelmRepositories';
|
||||
import { useHelmRepoOptions } from '../helmChartSourceQueries/useHelmRepositories';
|
||||
|
||||
import { HelmInstallForm } from './HelmInstallForm';
|
||||
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -6,6 +6,8 @@ import { withGlobalError } from '@/react-tools/react-query';
|
|||
|
||||
import { Chart, HelmChartsResponse } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
/**
|
||||
* React hook to fetch helm charts from the provided HTTP repository.
|
||||
* Charts are loaded from the specified repository URL.
|
||||
|
@ -21,7 +23,7 @@ export function useHelmHTTPChartList(
|
|||
enabled: boolean
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [userId, repository, 'helm-charts'],
|
||||
queryKey: queryKeys.charts(userId, repository),
|
||||
queryFn: () => getChartsFromRepo(repository),
|
||||
enabled: !!userId && !!repository && enabled,
|
||||
// one request takes a long time, so fail early to get feedback to the user faster
|
|
@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
type Params = {
|
||||
/** The name of the chart to get the values for */
|
||||
chart: string;
|
||||
|
@ -12,6 +14,26 @@ type Params = {
|
|||
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) {
|
||||
try {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
});
|
||||
}
|
|
@ -5,6 +5,8 @@ import { compact, flatMap } from 'lodash';
|
|||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
interface HelmSearch {
|
||||
entries: Entries;
|
||||
}
|
||||
|
@ -44,9 +46,9 @@ export function useHelmRepoVersions(
|
|||
queries: useMemo(
|
||||
() =>
|
||||
repoSources.map(({ repo }) => ({
|
||||
queryKey: ['helm', 'repositories', chart, repo, useCache],
|
||||
queryKey: queryKeys.chartVersions(repo || '', chart),
|
||||
queryFn: () => getSearchHelmRepo({ repo, chart, useCache }),
|
||||
enabled: !!chart && repoSources.length > 0,
|
||||
enabled: !!chart && !!repo,
|
||||
staleTime,
|
||||
...withGlobalError(`Unable to retrieve versions from ${repo}`),
|
||||
})),
|
|
@ -10,17 +10,19 @@ import { Option } from '@/react/components/form-components/PortainerSelect';
|
|||
import { HelmRegistriesResponse } from '../types';
|
||||
import { RepoValue } from '../components/HelmRegistrySelect';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
/**
|
||||
* Hook to fetch all Helm registries for the current user
|
||||
*/
|
||||
export function useUserHelmRepositories<T = string[]>({
|
||||
select,
|
||||
}: {
|
||||
select?: (registries: string[]) => T;
|
||||
select?: (registries: HelmRegistriesResponse) => T;
|
||||
} = {}) {
|
||||
const { user } = useCurrentUser();
|
||||
return useQuery(
|
||||
['helm', 'registries'],
|
||||
queryKeys.registries(user.Id),
|
||||
async () => getUserHelmRepositories(user.Id),
|
||||
{
|
||||
enabled: !!user.Id,
|
||||
|
@ -33,7 +35,8 @@ export function useUserHelmRepositories<T = string[]>({
|
|||
export function useHelmRepoOptions() {
|
||||
return useUserHelmRepositories({
|
||||
select: (registries) => {
|
||||
const repoOptions = registries
|
||||
const registryArray = flattenHelmRegistries(registries);
|
||||
const repoOptions = registryArray
|
||||
.map<Option<RepoValue>>((registry) => ({
|
||||
label: registry,
|
||||
value: {
|
||||
|
@ -72,13 +75,18 @@ async function getUserHelmRepositories(userId: UserId) {
|
|||
const { data } = await axios.get<HelmRegistriesResponse>(
|
||||
`users/${userId}/helm/repositories`
|
||||
);
|
||||
// compact will remove the global repository if it's empty
|
||||
const repos = compact([
|
||||
data.GlobalRepository.toLowerCase(),
|
||||
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
|
||||
]);
|
||||
return [...new Set(repos)];
|
||||
return data;
|
||||
} catch (err) {
|
||||
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)];
|
||||
}
|
47
app/react/kubernetes/helm/helmReleaseQueries/query-keys.ts
Normal file
47
app/react/kubernetes/helm/helmReleaseQueries/query-keys.ts
Normal 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,
|
||||
};
|
|
@ -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
|
||||
});
|
||||
}
|
|
@ -4,7 +4,9 @@ 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';
|
||||
import { HelmRelease } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useHelmHistory(
|
||||
environmentId: EnvironmentId,
|
||||
|
@ -12,7 +14,7 @@ export function useHelmHistory(
|
|||
namespace: string
|
||||
) {
|
||||
return useQuery(
|
||||
[environmentId, 'helm', 'releases', namespace, name, 'history'],
|
||||
queryKeys.releaseHistory(environmentId, namespace, name),
|
||||
() => getHelmHistory(environmentId, name, namespace),
|
||||
{
|
||||
enabled: !!environmentId && !!name && !!namespace,
|
|
@ -4,7 +4,9 @@ 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';
|
||||
import { HelmRelease } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
type Options<T> = {
|
||||
select?: (data: HelmRelease) => T;
|
||||
|
@ -27,15 +29,7 @@ export function useHelmRelease<T = HelmRelease>(
|
|||
const { select, showResources, refetchInterval, revision, staleTime } =
|
||||
options;
|
||||
return useQuery(
|
||||
[
|
||||
environmentId,
|
||||
'helm',
|
||||
'releases',
|
||||
namespace,
|
||||
name,
|
||||
revision,
|
||||
showResources,
|
||||
],
|
||||
queryKeys.release(environmentId, namespace, name, revision, showResources),
|
||||
() =>
|
||||
getHelmRelease(environmentId, name, {
|
||||
namespace,
|
|
@ -7,7 +7,9 @@ import {
|
|||
withGlobalError,
|
||||
} from '@/react-tools/react-query';
|
||||
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
|
||||
|
@ -54,8 +56,8 @@ export function useHelmRollbackMutation(environmentId: EnvironmentId) {
|
|||
rollbackRelease({ releaseName, params, environmentId }),
|
||||
...withGlobalError('Unable to rollback Helm release'),
|
||||
...withInvalidate(queryClient, [
|
||||
[environmentId, 'helm', 'releases'],
|
||||
queryKeys.applications(environmentId),
|
||||
queryKeys.releases(environmentId),
|
||||
applicationsQueryKeys.applications(environmentId),
|
||||
]),
|
||||
});
|
||||
}
|
|
@ -5,6 +5,8 @@ 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 { queryKeys } from './query-keys';
|
||||
|
||||
export function useUninstallHelmAppMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
@ -16,6 +18,7 @@ export function useUninstallHelmAppMutation(environmentId: EnvironmentId) {
|
|||
namespace?: string;
|
||||
}) => uninstallHelmApplication(environmentId, releaseName, namespace),
|
||||
...withInvalidate(queryClient, [
|
||||
queryKeys.releases(environmentId),
|
||||
applicationsQueryKeys.applications(environmentId),
|
||||
]),
|
||||
...withGlobalError('Unable to uninstall helm application'),
|
|
@ -7,30 +7,48 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||
|
||||
import { HelmRelease, UpdateHelmReleasePayload } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: UpdateHelmReleasePayload) =>
|
||||
updateHelmRelease(environmentId, payload),
|
||||
...withInvalidate(queryClient, [
|
||||
[environmentId, 'helm', 'releases'],
|
||||
queryKeys.releases(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,
|
||||
payload: UpdateHelmReleasePayload
|
||||
payload: UpdateHelmReleasePayload,
|
||||
params: UpdateHelmReleaseParams = {},
|
||||
options: UpdateHelmReleaseOptions = {}
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.post<HelmRelease>(
|
||||
`endpoints/${environmentId}/kubernetes/helm`,
|
||||
payload
|
||||
payload,
|
||||
{
|
||||
params,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to update helm release');
|
||||
throw parseAxiosError(
|
||||
err,
|
||||
options.errorMessage ?? 'Unable to update helm release'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,6 +4,8 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
import { success as notifySuccess } from '@/portainer/services/notifications';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { queryKeys } from '@/react/kubernetes/helm/helmChartSourceQueries/query-keys';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import {
|
||||
CreateHelmRepositoryPayload,
|
||||
|
@ -52,11 +54,12 @@ export async function deleteHelmRepositories(repos: HelmRepository[]) {
|
|||
|
||||
export function useDeleteHelmRepositoryMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation(deleteHelmRepository, {
|
||||
onSuccess: (_, helmRepository) => {
|
||||
notifySuccess('Helm repository deleted successfully', helmRepository.URL);
|
||||
return queryClient.invalidateQueries(['helmrepositories']);
|
||||
return queryClient.invalidateQueries(queryKeys.registries(user.Id));
|
||||
},
|
||||
...withError('Unable to delete Helm repository'),
|
||||
});
|
||||
|
@ -64,6 +67,7 @@ export function useDeleteHelmRepositoryMutation() {
|
|||
|
||||
export function useDeleteHelmRepositoriesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation(deleteHelmRepositories, {
|
||||
onSuccess: () => {
|
||||
|
@ -75,26 +79,31 @@ export function useDeleteHelmRepositoriesMutation() {
|
|||
'repositories'
|
||||
)} deleted successfully`
|
||||
);
|
||||
return queryClient.invalidateQueries(['helmrepositories']);
|
||||
return queryClient.invalidateQueries(queryKeys.registries(user.Id));
|
||||
},
|
||||
...withError('Unable to delete Helm repositories'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useHelmRepositories(userId: number) {
|
||||
return useQuery(['helmrepositories'], () => getHelmRepositories(userId), {
|
||||
return useQuery(
|
||||
queryKeys.registries(userId),
|
||||
() => getHelmRepositories(userId),
|
||||
{
|
||||
staleTime: 20,
|
||||
...withError('Unable to retrieve Helm repositories'),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useCreateHelmRepositoryMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation(createHelmRepository, {
|
||||
onSuccess: (_, payload) => {
|
||||
notifySuccess('Helm repository created successfully', payload.URL);
|
||||
return queryClient.invalidateQueries(['helmrepositories']);
|
||||
return queryClient.invalidateQueries(queryKeys.registries(user.Id));
|
||||
},
|
||||
...withError('Unable to create Helm repository'),
|
||||
});
|
||||
|
|
|
@ -1,7 +1,36 @@
|
|||
export function mockCodeMirror() {
|
||||
vi.mock('@uiw/react-codemirror', () => ({
|
||||
__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: {},
|
||||
keymap: {
|
||||
of: () => ({}),
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
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
|
||||
Range.prototype.getBoundingClientRect = () => ({
|
||||
bottom: 0,
|
||||
|
|
|
@ -17,6 +17,7 @@ type InstallOptions struct {
|
|||
ValuesFile string
|
||||
PostRenderer string
|
||||
Atomic bool
|
||||
DryRun bool
|
||||
Timeout time.Duration
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
|
||||
|
|
|
@ -113,6 +113,10 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||
Str("namespace", installOpts.Namespace).
|
||||
Err(err).
|
||||
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")
|
||||
}
|
||||
|
||||
|
@ -142,6 +146,7 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I
|
|||
installClient.Wait = installOpts.Wait
|
||||
installClient.Timeout = installOpts.Timeout
|
||||
installClient.Version = installOpts.Version
|
||||
installClient.DryRun = installOpts.DryRun
|
||||
err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation")
|
||||
|
|
|
@ -165,6 +165,7 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
|
|||
upgradeClient.Atomic = upgradeOpts.Atomic
|
||||
upgradeClient.Wait = upgradeOpts.Wait
|
||||
upgradeClient.Version = upgradeOpts.Version
|
||||
upgradeClient.DryRun = upgradeOpts.DryRun
|
||||
err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue