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

feat(helm): enhance helm chart install [r8s-341] (#766)

This commit is contained in:
Ali 2025-06-05 13:13:45 +12:00 committed by GitHub
parent caac45b834
commit a9061e5258
29 changed files with 864 additions and 562 deletions

View file

@ -20,6 +20,7 @@ import (
// @tags helm // @tags helm
// @param repo query string true "Helm repository URL" // @param repo query string true "Helm repository URL"
// @param chart query string true "Chart name" // @param chart query string true "Chart name"
// @param version query string true "Chart version"
// @param command path string true "chart/values/readme" // @param command path string true "chart/values/readme"
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
@ -45,6 +46,11 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter")) return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
} }
version, err := request.RetrieveQueryParameter(r, "version", true)
if err != nil {
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version)))
}
cmd, err := request.RetrieveRouteVariableValue(r, "command") cmd, err := request.RetrieveRouteVariableValue(r, "command")
if err != nil { if err != nil {
cmd = "all" cmd = "all"
@ -55,6 +61,7 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
OutputFormat: options.ShowOutputFormat(cmd), OutputFormat: options.ShowOutputFormat(cmd),
Chart: chart, Chart: chart,
Repo: repo, Repo: repo,
Version: version,
} }
result, err := handler.helmPackageManager.Show(showOptions) result, err := handler.helmPackageManager.Show(showOptions)
if err != nil { if err != nil {

View file

@ -31,7 +31,7 @@
<portainer-tooltip message="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'"> <portainer-tooltip message="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'">
</portainer-tooltip> </portainer-tooltip>
</label> </label>
<div class="col-sm-8 vertical-center pt-1"> <div class="col-sm-9 col-lg-10 vertical-center pt-1">
<label class="switch"> <label class="switch">
<input type="checkbox" name="toggle_logo" ng-model="ctrl.formValues.namespace_toggle" data-cy="use-namespce-from-menifest" /> <input type="checkbox" name="toggle_logo" ng-model="ctrl.formValues.namespace_toggle" data-cy="use-namespce-from-menifest" />
<span class="slider round"></span> <span class="slider round"></span>
@ -41,7 +41,7 @@
<div class="form-group" ng-if="ctrl.formValues.Namespace"> <div class="form-group" ng-if="ctrl.formValues.Namespace">
<label for="target_node" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label> <label for="target_node" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label>
<div class="col-sm-8"> <div class="col-sm-9 col-lg-10">
<select <select
ng-if="!ctrl.formValues.namespace_toggle || ctrl.state.BuildMethod === ctrl.BuildMethods.HELM" ng-if="!ctrl.formValues.namespace_toggle || ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"
data-cy="namespace-select" data-cy="namespace-select"
@ -66,10 +66,10 @@
<div class="form-group"> <div class="form-group">
<label for="name" class="col-lg-2 col-sm-3 control-label text-left" ng-class="{ required: ctrl.state.BuildMethod === ctrl.BuildMethods.HELM }">Name</label> <label for="name" class="col-lg-2 col-sm-3 control-label text-left" ng-class="{ required: ctrl.state.BuildMethod === ctrl.BuildMethods.HELM }">Name</label>
<div class="col-sm-8 small text-muted pt-[7px]" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"> <div class="col-sm-9 col-lg-10 small text-muted pt-[7px]" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
Resource names specified in the manifest will be used Resource names specified in the manifest will be used
</div> </div>
<div class="col-sm-8" ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"> <div class="col-sm-9 col-lg-10" ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<input <input
type="text" type="text"
data-cy="name-input" data-cy="name-input"
@ -170,7 +170,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="manifest_url" class="col-sm-3 col-lg-2 control-label required text-left">URL</label> <label for="manifest_url" class="col-sm-3 col-lg-2 control-label required text-left">URL</label>
<div class="col-sm-8"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
data-cy="k8sAppDeploy-urlFileUrl" data-cy="k8sAppDeploy-urlFileUrl"

View file

@ -1,7 +1,7 @@
<div> <div>
<div class="form-group pt-3"> <div class="form-group pt-3">
<label for="stack_template" class="col-sm-3 col-lg-2 control-label text-left"> Template </label> <label for="stack_template" class="col-sm-3 col-lg-2 control-label text-left"> Template </label>
<div class="col-sm-8 col-sm-8 flex flex-col gap-y-1"> <div class="col-sm-9 col-lg-10 flex flex-col gap-y-1">
<select <select
ng-if="$ctrl.templates.length" ng-if="$ctrl.templates.length"
data-cy="custom-template-selector" data-cy="custom-template-selector"
@ -10,7 +10,7 @@
ng-options="template.Id as template.label for template in $ctrl.templates" ng-options="template.Id as template.label for template in $ctrl.templates"
ng-change="$ctrl.handleChangeTemplate($ctrl.value)" ng-change="$ctrl.handleChangeTemplate($ctrl.value)"
> >
<option value="" label="Select a Custom template" disabled selected="selected"> </option> <option value="" label="Select a Custom Template" disabled selected="selected"> </option>
</select> </select>
<span ng-if="$ctrl.isLoadFailed"> <span ng-if="$ctrl.isLoadFailed">
<p class="text-warning mb-5 !inline-flex gap-1 !align-top text-xs" ng-if="ctrl.currentUser.isAdmin || ctrl.currentUser.id === ctrl.state.template.CreatedByUserId"> <p class="text-warning mb-5 !inline-flex gap-1 !align-top text-xs" ng-if="ctrl.currentUser.isAdmin || ctrl.currentUser.id === ctrl.state.template.CreatedByUserId">

View file

@ -235,6 +235,7 @@ export const ngModule = angular
'schema', 'schema',
'fileName', 'fileName',
'placeholder', 'placeholder',
'showToolbar',
]) ])
) )
.component( .component(

View file

@ -141,9 +141,11 @@
} }
.root :global(.cm-content[aria-readonly='true']) { .root :global(.cm-content[aria-readonly='true']) {
@apply bg-gray-3; /* make sure the bg has transparency, so that the selected text is visible */
@apply th-dark:bg-gray-iron-10; /* https://discuss.codemirror.net/t/how-do-i-get-selected-text-to-highlight/7115/2 */
@apply th-highcontrast:bg-black; @apply bg-gray-3/50;
@apply th-dark:bg-gray-iron-10/50;
@apply th-highcontrast:bg-black/50;
} }
.root :global(.cm-textfield) { .root :global(.cm-textfield) {

View file

@ -33,6 +33,7 @@ interface Props extends AutomationTestingProps {
schema?: JSONSchema7; schema?: JSONSchema7;
fileName?: string; fileName?: string;
placeholder?: string; placeholder?: string;
showToolbar?: boolean;
} }
export const theme = createTheme({ export const theme = createTheme({
@ -75,6 +76,7 @@ export function CodeEditor({
'data-cy': dataCy, 'data-cy': dataCy,
fileName, fileName,
placeholder, placeholder,
showToolbar = true,
}: Props) { }: Props) {
const [isRollback, setIsRollback] = useState(false); const [isRollback, setIsRollback] = useState(false);
@ -94,38 +96,40 @@ export function CodeEditor({
return ( return (
<> <>
<div className="mb-2 flex flex-col"> {showToolbar && (
<div className="flex items-center justify-between"> <div className="mb-2 flex flex-col">
<div className="flex items-center"> <div className="flex items-center justify-between">
{!!textTip && <TextTip color="blue">{textTip}</TextTip>} <div className="flex items-center">
{!!textTip && <TextTip color="blue">{textTip}</TextTip>}
</div>
{/* the copy button is in the file name header, when fileName is provided */}
{!fileName && (
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
<CopyButton
data-cy={`copy-code-button-${id}`}
fadeDelay={2500}
copyText={value}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
</div>
)}
</div> </div>
{/* the copy button is in the file name header, when fileName is provided */} {versions && (
{!fileName && ( <div className="mt-2 flex">
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2"> <div className="ml-auto mr-2">
<CopyButton <StackVersionSelector
data-cy={`copy-code-button-${id}`} versions={versions}
fadeDelay={2500} onChange={handleVersionChange}
copyText={value} />
color="link" </div>
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
</div> </div>
)} )}
</div> </div>
{versions && ( )}
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
<div className="overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2"> <div className="overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2">
{fileName && ( {fileName && (
<FileNameHeaderRow> <FileNameHeaderRow>

View file

@ -0,0 +1,52 @@
import { BROWSER_OS_PLATFORM } from '@/react/constants';
import { Tooltip } from '@@/Tip/Tooltip';
const otherEditorConfig = {
tooltip: (
<>
<div>Ctrl+F - Start searching</div>
<div>Ctrl+G - Find next</div>
<div>Ctrl+Shift+G - Find previous</div>
<div>Ctrl+Shift+F - Replace</div>
<div>Ctrl+Shift+R - Replace all</div>
<div>Alt+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Ctrl+F for search',
} as const;
export const editorConfig = {
mac: {
tooltip: (
<>
<div>Cmd+F - Start searching</div>
<div>Cmd+G - Find next</div>
<div>Cmd+Shift+G - Find previous</div>
<div>Cmd+Option+F - Replace</div>
<div>Cmd+Option+R - Replace all</div>
<div>Option+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Cmd+F for search',
},
lin: otherEditorConfig,
win: otherEditorConfig,
} as const;
export function ShortcutsTooltip() {
return (
<div className="text-muted small vertical-center ml-auto">
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
</div>
);
}

View file

@ -0,0 +1,32 @@
import { ExternalLink as ExternalLinkIcon } from 'lucide-react';
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon';
interface Props {
to: string;
className?: string;
}
export function ExternalLink({
to,
className,
children,
'data-cy': dataCy,
}: PropsWithChildren<Props & AutomationTestingProps>) {
return (
<a
href={to}
target="_blank"
rel="noreferrer"
data-cy={dataCy}
className={clsx('inline-flex items-center gap-1', className)}
>
<Icon icon={ExternalLinkIcon} />
<span>{children}</span>
</a>
);
}

View file

@ -8,55 +8,14 @@ import {
import { useTransitionHook } from '@uirouter/react'; import { useTransitionHook } from '@uirouter/react';
import { JSONSchema7 } from 'json-schema'; import { JSONSchema7 } from 'json-schema';
import { BROWSER_OS_PLATFORM } from '@/react/constants';
import { CodeEditor } from '@@/CodeEditor'; import { CodeEditor } from '@@/CodeEditor';
import { Tooltip } from '@@/Tip/Tooltip';
import { FormSectionTitle } from './form-components/FormSectionTitle'; import { FormSectionTitle } from './form-components/FormSectionTitle';
import { FormError } from './form-components/FormError'; import { FormError } from './form-components/FormError';
import { confirm } from './modals/confirm'; import { confirm } from './modals/confirm';
import { ModalType } from './modals'; import { ModalType } from './modals';
import { buildConfirmButton } from './modals/utils'; import { buildConfirmButton } from './modals/utils';
import { ShortcutsTooltip } from './CodeEditor/ShortcutsTooltip';
const otherEditorConfig = {
tooltip: (
<>
<div>Ctrl+F - Start searching</div>
<div>Ctrl+G - Find next</div>
<div>Ctrl+Shift+G - Find previous</div>
<div>Ctrl+Shift+F - Replace</div>
<div>Ctrl+Shift+R - Replace all</div>
<div>Alt+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Ctrl+F for search',
} as const;
export const editorConfig = {
mac: {
tooltip: (
<>
<div>Cmd+F - Start searching</div>
<div>Cmd+G - Find next</div>
<div>Cmd+Shift+G - Find previous</div>
<div>Cmd+Option+F - Replace</div>
<div>Cmd+Option+R - Replace all</div>
<div>Option+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Cmd+F for search',
},
lin: otherEditorConfig,
win: otherEditorConfig,
} as const;
type CodeEditorProps = ComponentProps<typeof CodeEditor>; type CodeEditorProps = ComponentProps<typeof CodeEditor>;
@ -69,7 +28,7 @@ interface Props extends CodeEditorProps {
export function WebEditorForm({ export function WebEditorForm({
id, id,
titleContent = '', titleContent = 'Web editor',
hideTitle, hideTitle,
children, children,
error, error,
@ -81,10 +40,7 @@ export function WebEditorForm({
<div> <div>
<div className="web-editor overflow-x-hidden"> <div className="web-editor overflow-x-hidden">
{!hideTitle && ( {!hideTitle && (
<> <DefaultTitle id={id}>{titleContent ?? null}</DefaultTitle>
<DefaultTitle id={id} />
{titleContent ?? null}
</>
)} )}
{children && ( {children && (
<div className="form-group text-muted small"> <div className="form-group text-muted small">
@ -111,15 +67,11 @@ export function WebEditorForm({
); );
} }
function DefaultTitle({ id }: { id: string }) { function DefaultTitle({ id, children }: { id: string; children?: ReactNode }) {
return ( return (
<FormSectionTitle htmlFor={id}> <FormSectionTitle htmlFor={id}>
Web editor {children}
<div className="text-muted small vertical-center ml-auto"> <ShortcutsTooltip />
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
</div>
</FormSectionTitle> </FormSectionTitle>
); );
} }

View file

@ -21,7 +21,7 @@ interface Props {
onDismiss?(): void; onDismiss?(): void;
'aria-label'?: string; 'aria-label'?: string;
'aria-labelledby'?: string; 'aria-labelledby'?: string;
size?: 'md' | 'lg'; size?: 'md' | 'lg' | 'xl';
className?: string; className?: string;
} }
@ -53,6 +53,7 @@ export function Modal({
{ {
'w-[450px]': size === 'md', 'w-[450px]': size === 'md',
'w-[700px]': size === 'lg', 'w-[700px]': size === 'lg',
'w-[1000px]': size === 'xl',
} }
)} )}
> >

View file

@ -70,7 +70,7 @@ export function StackName({
Stack Stack
<Tooltip message={tooltip} setHtmlMessage /> <Tooltip message={tooltip} setHtmlMessage />
</label> </label>
<div className={inputClassName || 'col-sm-8'}> <div className={inputClassName || 'col-sm-9 col-lg-10'}>
<AutocompleteSelect <AutocompleteSelect
searchResults={stackResults?.map((result) => ({ searchResults={stackResults?.map((result) => ({
value: result, value: result,

View file

@ -4,11 +4,12 @@ import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { import {
useHelmRepoVersions, useHelmRepoVersions,
ChartVersion, ChartVersion,
} from '../queries/useHelmRepositories'; } from '../../queries/useHelmRepositories';
import { HelmRelease } from '../../types'; import { HelmRelease } from '../../types';
import { openUpgradeHelmModal } from './UpgradeHelmModal'; import { openUpgradeHelmModal } from './UpgradeHelmModal';
@ -25,17 +26,8 @@ vi.mock('@/portainer/services/notifications', () => ({
})); }));
// Mock the useHelmRepoVersions and useHelmRepositories hooks // Mock the useHelmRepoVersions and useHelmRepositories hooks
vi.mock('../queries/useHelmRepositories', () => ({ vi.mock('../../queries/useHelmRepositories', () => ({
useHelmRepoVersions: vi.fn(() => ({ useHelmRepoVersions: vi.fn(),
data: [
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
],
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
})),
useHelmRepositories: vi.fn(() => ({ useHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'], data: ['repo1', 'repo2'],
isInitialLoading: false, isInitialLoading: false,
@ -43,6 +35,21 @@ vi.mock('../queries/useHelmRepositories', () => ({
})), })),
})); }));
// Mock the useHelmRelease hook
vi.mock('../queries/useHelmRelease', () => ({
useHelmRelease: vi.fn(() => ({
data: '1.0.0',
})),
}));
// Mock the useUpdateHelmReleaseMutation hook
vi.mock('../../queries/useUpdateHelmReleaseMutation', () => ({
useUpdateHelmReleaseMutation: vi.fn(() => ({
mutate: vi.fn(),
isLoading: false,
})),
}));
function renderButton(props = {}) { function renderButton(props = {}) {
const defaultProps = { const defaultProps = {
environmentId: 1, environmentId: 1,
@ -65,11 +72,27 @@ function renderButton(props = {}) {
...props, ...props,
}; };
const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton)); const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(UpgradeButton))
);
return render(<Wrapped {...defaultProps} />); return render(<Wrapped {...defaultProps} />);
} }
describe('UpgradeButton', () => { describe('UpgradeButton', () => {
beforeEach(() => {
// Set up default mock return values
vi.mocked(useHelmRepoVersions).mockReturnValue({
data: [
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
],
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
});
test('should display the upgrade button', () => { test('should display the upgrade button', () => {
renderButton(); renderButton();

View file

@ -6,21 +6,17 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications'; import { notifySuccess } from '@/portainer/services/notifications';
import { semverCompare } from '@/react/common/semver-utils'; import { semverCompare } from '@/react/common/semver-utils';
import { LoadingButton } from '@@/buttons'; import { Button, LoadingButton } from '@@/buttons';
import { InlineLoader } from '@@/InlineLoader'; import { InlineLoader } from '@@/InlineLoader';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { HelmRelease } from '../../types'; import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
import { import {
useUpdateHelmReleaseMutation,
UpdateHelmReleasePayload,
} from '../queries/useUpdateHelmReleaseMutation';
import {
ChartVersion,
useHelmRepoVersions, useHelmRepoVersions,
useHelmRepositories, useHelmRepositories,
} from '../queries/useHelmRepositories'; } from '../../queries/useHelmRepositories';
import { useHelmRelease } from '../queries/useHelmRelease'; import { useHelmRelease } from '../queries/useHelmRelease';
import { openUpgradeHelmModal } from './UpgradeHelmModal'; import { openUpgradeHelmModal } from './UpgradeHelmModal';
@ -39,10 +35,10 @@ export function UpgradeButton({
updateRelease: (release: HelmRelease) => void; updateRelease: (release: HelmRelease) => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const [useCache, setUseCache] = useState(true);
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId); const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const repositoriesQuery = useHelmRepositories(); const repositoriesQuery = useHelmRepositories();
const [useCache, setUseCache] = useState(true);
const helmRepoVersionsQuery = useHelmRepoVersions( const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '', release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour 60 * 60 * 1000, // 1 hour
@ -50,43 +46,42 @@ export function UpgradeButton({
useCache useCache
); );
const versions = helmRepoVersionsQuery.data; const versions = helmRepoVersionsQuery.data;
const repo = versions?.[0]?.Repo;
// Combined loading state // Combined loading state
const isLoading = const isLoading =
repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError; const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersionQuery = useHelmRelease(
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, { environmentId,
select: (data) => data.chart.metadata?.version, releaseName,
}); namespace,
{
select: (data) => data.chart.metadata?.version,
}
);
const latestVersionAvailable = versions[0]?.Version ?? ''; const latestVersionAvailable = versions[0]?.Version ?? '';
const isNewVersionAvailable = Boolean( const isNewVersionAvailable = Boolean(
latestVersion?.data && latestVersionQuery?.data &&
semverCompare(latestVersionAvailable, latestVersion?.data) === 1 semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1
); );
const currentVersion = release?.chart.metadata?.version;
const editableHelmRelease: UpdateHelmReleasePayload = { const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName, name: releaseName,
namespace: namespace || '', namespace: namespace || '',
values: release?.values?.userSuppliedValues, values: release?.values?.userSuppliedValues,
chart: release?.chart.metadata?.name || '', chart: release?.chart.metadata?.name || '',
version: release?.chart.metadata?.version, version: currentVersion,
repo,
}; };
function handleRefreshVersions() {
if (!useCache) {
helmRepoVersionsQuery.refetch();
} else {
setUseCache(false);
}
}
return ( return (
<div className="relative"> <div className="relative">
<LoadingButton <LoadingButton
color="secondary" color="secondary"
data-cy="k8sApp-upgradeHelmChartButton" data-cy="k8sApp-upgradeHelmChartButton"
onClick={() => openUpgradeForm(versions, release)} onClick={handleUpgrade}
disabled={ disabled={
versions.length === 0 || versions.length === 0 ||
isLoading || isLoading ||
@ -133,68 +128,75 @@ export function UpgradeButton({
} }
/> />
)} )}
<button <Button
data-cy="k8sApp-refreshHelmChartVersionsButton"
color="link"
size="xsmall"
onClick={handleRefreshVersions} onClick={handleRefreshVersions}
className="text-primary hover:text-primary-light cursor-pointer bg-transparent border-0 pl-1 p-0"
type="button" type="button"
> >
Refresh versions Refresh
</button> </Button>
</span> </span>
)} )}
</div> </div>
); );
async function openUpgradeForm( function handleRefreshVersions() {
versions: ChartVersion[], if (useCache) {
release?: HelmRelease // clicking 'refresh versions' should get the latest versions from the repo, not the cached versions
) { setUseCache(false);
const result = await openUpgradeHelmModal(editableHelmRelease, versions);
if (result) {
handleUpgrade(result, release);
} }
helmRepoVersionsQuery.refetch();
} }
function handleUpgrade( async function handleUpgrade() {
payload: UpdateHelmReleasePayload, const submittedUpgradeValues = await openUpgradeHelmModal(
release?: HelmRelease editableHelmRelease,
) { versions
if (release?.info) { );
const updatedRelease = {
...release, if (submittedUpgradeValues) {
info: { upgrade(submittedUpgradeValues, release);
...release.info, }
status: 'pending-upgrade',
description: 'Preparing upgrade', function upgrade(payload: UpdateHelmReleasePayload, release?: HelmRelease) {
if (release?.info) {
const updatedRelease = {
...release,
info: {
...release.info,
status: 'pending-upgrade',
description: 'Preparing upgrade',
},
};
updateRelease(updatedRelease);
}
updateHelmReleaseMutation.mutate(payload, {
onSuccess: () => {
notifySuccess('Success', 'Helm chart upgraded successfully');
// set the revision url param to undefined to refresh the page at the latest revision
router.stateService.go('kubernetes.helm', {
namespace,
name: releaseName,
revision: undefined,
});
}, },
}; });
updateRelease(updatedRelease);
} }
updateHelmReleaseMutation.mutate(payload, {
onSuccess: () => {
notifySuccess('Success', 'Helm chart upgraded successfully');
// set the revision url param to undefined to refresh the page at the latest revision
router.stateService.go('kubernetes.helm', {
namespace,
name: releaseName,
revision: undefined,
});
},
});
}
function getStatusMessage(
hasNoAvailableVersions: boolean,
latestVersionAvailable: string,
isNewVersionAvailable: boolean
): string {
if (hasNoAvailableVersions) {
return 'No versions available ';
}
if (isNewVersionAvailable) {
return `New version available (${latestVersionAvailable}) `;
}
return '';
} }
} }
function getStatusMessage(
hasNoAvailableVersions: boolean,
latestVersionAvailable: string,
isNewVersionAvailable: boolean
) {
if (hasNoAvailableVersions) {
return 'No versions available ';
}
if (isNewVersionAvailable) {
return `New version available (${latestVersionAvailable}) `;
}
return 'Latest version installed';
}

View file

@ -3,26 +3,35 @@ 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/useHelmRepositories';
import { Modal, OnSubmit, openModal } from '@@/modals'; import { Modal, OnSubmit, openModal } from '@@/modals';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { CodeEditor } from '@@/CodeEditor';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { WidgetTitle } from '@@/Widget'; import { WidgetTitle } from '@@/Widget';
import { Checkbox } from '@@/form-components/Checkbox'; import { Checkbox } from '@@/form-components/Checkbox';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation'; import { UpdateHelmReleasePayload } from '../../types';
import { ChartVersion } from '../queries/useHelmRepositories'; import { HelmValuesInput } from '../../components/HelmValuesInput';
import { useHelmChartValues } from '../../queries/useHelmChartValues';
interface Props { interface Props {
onSubmit: OnSubmit<UpdateHelmReleasePayload>; onSubmit: OnSubmit<UpdateHelmReleasePayload>;
values: UpdateHelmReleasePayload; values: UpdateHelmReleasePayload;
versions: ChartVersion[]; versions: ChartVersion[];
chartName: string;
repo: string;
} }
export function UpgradeHelmModal({ values, versions, onSubmit }: Props) { export function UpgradeHelmModal({
values,
versions,
onSubmit,
chartName,
repo,
}: Props) {
const versionOptions: Option<ChartVersion>[] = versions.map((version) => { const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
const isCurrentVersion = version.Version === values.version; const isCurrentVersion = version.Version === values.version;
const label = `${version.Repo}@${version.Version}${ const label = `${version.Repo}@${version.Version}${
@ -38,11 +47,18 @@ export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
versionOptions[0]?.value; versionOptions[0]?.value;
const [version, setVersion] = useState<ChartVersion>(defaultVersion); const [version, setVersion] = useState<ChartVersion>(defaultVersion);
const [userValues, setUserValues] = useState<string>(values.values || ''); const [userValues, setUserValues] = useState<string>(values.values || '');
const [atomic, setAtomic] = useState<boolean>(false); const [atomic, setAtomic] = useState<boolean>(true);
const chartValuesRefQuery = useHelmChartValues({
chart: chartName,
repo,
version: version.Version,
});
return ( return (
<Modal <Modal
onDismiss={() => onSubmit()} onDismiss={() => onSubmit()}
size="lg" size="xl"
className="flex flex-col h-[80vh] px-0" className="flex flex-col h-[80vh] px-0"
aria-label="upgrade-helm" aria-label="upgrade-helm"
> >
@ -51,73 +67,65 @@ export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
/> />
<div className="flex-1 overflow-y-auto px-5"> <div className="flex-1 overflow-y-auto px-5">
<Modal.Body> <Modal.Body>
<FormControl label="Version" inputId="version-input" size="vertical"> <div className="form-horizontal">
<PortainerSelect<ChartVersion> <FormControl
value={version} label="Release name"
options={versionOptions} inputId="release-name-input"
onChange={(version) => { size="medium"
if (version) { >
setVersion(version); <Input
} id="release-name-input"
}} value={values.name}
data-cy="helm-version-input" readOnly
disabled
data-cy="helm-release-name-input"
/>
</FormControl>
<FormControl
label="Namespace"
inputId="namespace-input"
size="medium"
>
<Input
id="namespace-input"
value={values.namespace}
readOnly
disabled
data-cy="helm-namespace-input"
/>
</FormControl>
<FormControl label="Version" inputId="version-input" size="medium">
<PortainerSelect<ChartVersion>
value={version}
options={versionOptions}
onChange={(version) => {
if (version) {
setVersion(version);
}
}}
data-cy="helm-version-input"
/>
</FormControl>
<FormControl
label="Rollback on failure"
tooltip="Enables automatic rollback on failure (equivalent to the helm --atomic flag). It may increase the time to upgrade."
inputId="atomic-input"
size="medium"
>
<Checkbox
id="atomic-input"
checked={atomic}
data-cy="atomic-checkbox"
onChange={(e) => setAtomic(e.target.checked)}
/>
</FormControl>
<HelmValuesInput
values={userValues}
setValues={setUserValues}
valuesRef={chartValuesRefQuery.data?.values ?? ''}
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
/> />
</FormControl> </div>
<FormControl
label="Release name"
inputId="release-name-input"
size="vertical"
>
<Input
id="release-name-input"
value={values.name}
readOnly
disabled
data-cy="helm-release-name-input"
/>
</FormControl>
<FormControl
label="Namespace"
inputId="namespace-input"
size="vertical"
>
<Input
id="namespace-input"
value={values.namespace}
readOnly
disabled
data-cy="helm-namespace-input"
/>
</FormControl>
<FormControl
label="Rollback on failure"
tooltip="Enables automatic rollback on failure (equivalent to the helm --atomic flag). It may increase the time to upgrade."
inputId="atomic-input"
className="[&>label]:!pl-0"
size="medium"
>
<Checkbox
id="atomic-input"
checked={atomic}
data-cy="atomic-checkbox"
onChange={(e) => setAtomic(e.target.checked)}
/>
</FormControl>
<FormControl
label="User-defined values"
inputId="user-values-editor"
size="vertical"
>
<CodeEditor
id="user-values-editor"
value={userValues}
onChange={(value) => setUserValues(value)}
height="50vh"
type="yaml"
data-cy="helm-user-values-editor"
placeholder="Define or paste the content of your values yaml file here"
/>
</FormControl>
</Modal.Body> </Modal.Body>
</div> </div>
<div className="px-5 border-solid border-0 border-t border-gray-5 th-dark:border-gray-7 th-highcontrast:border-white"> <div className="px-5 border-solid border-0 border-t border-gray-5 th-dark:border-gray-7 th-highcontrast:border-white">
@ -163,5 +171,7 @@ export async function openUpgradeHelmModal(
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), { return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
values, values,
versions, versions,
chartName: values.chart,
repo: values.repo ?? '',
}); });
} }

View file

@ -0,0 +1,174 @@
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 { Chart } from '../types';
import { HelmInstallForm } from './HelmInstallForm';
const mockMutate = vi.fn();
const mockNotifySuccess = vi.fn();
const mockTrackEvent = vi.fn();
const mockRouterGo = vi.fn();
// Mock the router hook to provide endpointId
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: '1' },
})),
useRouter: vi.fn(() => ({
stateService: {
go: vi.fn((...args) => mockRouterGo(...args)),
},
})),
}));
// Mock dependencies
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn((title: string, text: string) =>
mockNotifySuccess(title, text)
),
}));
vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({
useUpdateHelmReleaseMutation: vi.fn(() => ({
mutateAsync: vi.fn((...args) => mockMutate(...args)),
isLoading: false,
})),
}));
vi.mock('../queries/useHelmRepositories', () => ({
useHelmRepoVersions: vi.fn(() => ({
data: [
{ Version: '1.0.0', AppVersion: '1.0.0' },
{ Version: '0.9.0', AppVersion: '0.9.0' },
],
isInitialLoading: false,
})),
}));
vi.mock('./queries/useHelmChartValues', () => ({
useHelmChartValues: vi.fn().mockReturnValue({
data: { values: 'test-values' },
isInitialLoading: false,
}),
}));
vi.mock('@/react/hooks/useAnalytics', () => ({
useAnalytics: vi.fn().mockReturnValue({
trackEvent: vi.fn((...args) => mockTrackEvent(...args)),
}),
}));
// Sample test data
const mockChart: Chart = {
name: 'test-chart',
description: 'Test Chart Description',
repo: 'https://example.com',
icon: 'test-icon-url',
annotations: {
category: 'database',
},
};
const mockRouterStateService = {
go: vi.fn(),
};
function renderComponent({
selectedChart = mockChart,
namespace = 'test-namespace',
name = 'test-name',
isAdmin = true,
} = {}) {
const user = new UserViewModel({ Username: 'user', Role: isAdmin ? 1 : 2 });
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
/>
)),
user
)
);
return {
...render(<Wrapped />),
user,
mockRouterStateService,
};
}
describe('HelmInstallForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the form with version selector and values editor', async () => {
renderComponent();
expect(screen.getByText('Version')).toBeInTheDocument();
expect(screen.getByText('Install')).toBeInTheDocument();
});
it('should install helm chart when install button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const installButton = screen.getByText('Install');
await user.click(installButton);
// Check mutate was called with correct values
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test-name',
repo: 'https://example.com',
chart: 'test-chart',
values: '',
namespace: 'test-namespace',
version: '1.0.0',
}),
expect.objectContaining({ onSuccess: expect.any(Function) })
);
});
it('should disable install button when namespace or name is undefined', () => {
renderComponent({ namespace: '' });
expect(screen.getByText('Install')).toBeDisabled();
});
it('should call success handlers when installation succeeds', async () => {
const user = userEvent.setup();
renderComponent();
const installButton = screen.getByText('Install');
await user.click(installButton);
// Get the onSuccess callback and call it
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
onSuccessCallback();
// Check that success handlers were called
expect(mockTrackEvent).toHaveBeenCalledWith('kubernetes-helm-install', {
category: 'kubernetes',
metadata: {
'chart-name': 'test-chart',
},
});
expect(mockNotifySuccess).toHaveBeenCalledWith(
'Success',
'Helm chart successfully installed'
);
expect(mockRouterGo).toHaveBeenCalledWith('kubernetes.applications');
});
});

View file

@ -0,0 +1,106 @@
import { useRef } from 'react';
import { Formik, FormikProps } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { useCanExit } from '@/react/hooks/useCanExit';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { confirmGenericDiscard } from '@@/modals/confirm';
import { Option } from '@@/form-components/PortainerSelect';
import { Chart } from '../types';
import {
ChartVersion,
useHelmRepoVersions,
} from '../queries/useHelmRepositories';
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
import { HelmInstallFormValues } from './types';
type Props = {
selectedChart: Chart;
namespace?: string;
name?: string;
};
export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
const environmentId = useEnvironmentId();
const router = useRouter();
const analytics = useAnalytics();
const helmRepoVersionsQuery = useHelmRepoVersions(
selectedChart.name,
60 * 60 * 1000, // 1 hour
[selectedChart.repo],
false
);
const versions = helmRepoVersionsQuery.data;
const versionOptions: Option<ChartVersion>[] = versions.map(
(version, index) => ({
label: index === 0 ? `${version.Version} (latest)` : version.Version,
value: version,
})
);
const defaultVersion = versionOptions[0]?.value;
const initialValues: HelmInstallFormValues = {
values: '',
version: defaultVersion?.Version ?? '',
};
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
const formikRef = useRef<FormikProps<HelmInstallFormValues>>(null);
useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
return (
<Formik
innerRef={formikRef}
initialValues={initialValues}
enableReinitialize
onSubmit={handleSubmit}
>
<HelmInstallInnerForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
versionOptions={versionOptions}
isLoadingVersions={helmRepoVersionsQuery.isInitialLoading}
/>
</Formik>
);
async function handleSubmit(values: HelmInstallFormValues) {
if (!name || !namespace) {
// Theoretically this should never happen and is mainly to keep typescript happy
return;
}
await installHelmChartMutation.mutateAsync(
{
name,
repo: selectedChart.repo,
chart: selectedChart.name,
values: values.values,
namespace,
version: values.version,
},
{
onSuccess() {
analytics.trackEvent('kubernetes-helm-install', {
category: 'kubernetes',
metadata: {
'chart-name': selectedChart.name,
},
});
notifySuccess('Success', 'Helm chart successfully installed');
// Reset the form so page can be navigated away from without getting "Are you sure?"
formikRef.current?.resetForm();
router.stateService.go('kubernetes.applications');
},
}
);
}
}

View file

@ -0,0 +1,86 @@
import { Form, useFormikContext } from 'formik';
import { useMemo } from 'react';
import { FormActions } from '@@/form-components/FormActions';
import { FormControl } from '@@/form-components/FormControl';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormSection } from '@@/form-components/FormSection';
import { ChartVersion } from '../queries/useHelmRepositories';
import { Chart } from '../types';
import { useHelmChartValues } from '../queries/useHelmChartValues';
import { HelmValuesInput } from '../components/HelmValuesInput';
import { HelmInstallFormValues } from './types';
type Props = {
selectedChart: Chart;
namespace?: string;
name?: string;
versionOptions: Option<ChartVersion>[];
isLoadingVersions: boolean;
};
export function HelmInstallInnerForm({
selectedChart,
namespace,
name,
versionOptions,
isLoadingVersions,
}: Props) {
const { values, setFieldValue, isSubmitting } =
useFormikContext<HelmInstallFormValues>();
const chartValuesRefQuery = useHelmChartValues({
chart: selectedChart.name,
repo: selectedChart.repo,
version: values?.version,
});
const selectedVersion = useMemo(
() =>
versionOptions.find((v) => v.value.Version === values.version)?.value ??
versionOptions[0]?.value,
[versionOptions, values.version]
);
return (
<Form className="form-horizontal">
<div className="form-group !m-0">
<FormSection title="Configuration" className="mt-4">
<FormControl
label="Version"
inputId="version-input"
isLoading={isLoadingVersions}
loadingText="Loading versions..."
>
<PortainerSelect<ChartVersion>
value={selectedVersion}
options={versionOptions}
onChange={(version) => {
if (version) {
setFieldValue('version', version.Version);
}
}}
data-cy="helm-version-input"
/>
</FormControl>
<HelmValuesInput
values={values.values}
setValues={(values) => setFieldValue('values', values)}
valuesRef={chartValuesRefQuery.data?.values ?? ''}
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
/>
</FormSection>
</div>
<FormActions
submitLabel="Install"
loadingText="Installing Helm chart"
isLoading={isSubmitting}
isValid={!!namespace && !!name}
data-cy="helm-install"
/>
</Form>
);
}

View file

@ -10,6 +10,7 @@ import {
import { HelmTemplatesList } from './HelmTemplatesList'; import { HelmTemplatesList } from './HelmTemplatesList';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem'; import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
import { HelmInstallForm } from './HelmInstallForm';
interface Props { interface Props {
onSelectHelmChart: (chartName: string) => void; onSelectHelmChart: (chartName: string) => void;
@ -37,12 +38,17 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
<div className="row"> <div className="row">
<div className="col-sm-12 p-0"> <div className="col-sm-12 p-0">
{selectedChart ? ( {selectedChart ? (
<HelmTemplatesSelectedItem <>
selectedChart={selectedChart} <HelmTemplatesSelectedItem
clearHelmChart={clearHelmChart} selectedChart={selectedChart}
namespace={namespace} clearHelmChart={clearHelmChart}
name={name} />
/> <HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
/>
</>
) : ( ) : (
<HelmTemplatesList <HelmTemplatesList
charts={chartListQuery.data} charts={chartListQuery.data}

View file

@ -1,6 +1,4 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MutationOptions } from '@tanstack/react-query';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
@ -12,36 +10,6 @@ import { Chart } from '../types';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem'; import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
const mockMutate = vi.fn();
const mockNotifySuccess = vi.fn();
// Mock dependencies
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: (title: string, text: string) =>
mockNotifySuccess(title, text),
}));
vi.mock('./queries/useHelmChartValues', () => ({
useHelmChartValues: vi.fn().mockReturnValue({
data: { values: 'test-values' },
isLoading: false,
}),
}));
vi.mock('./queries/useHelmChartInstall', () => ({
useHelmChartInstall: vi.fn().mockReturnValue({
mutate: (params: Record<string, string>, options?: MutationOptions) =>
mockMutate(params, options),
isLoading: false,
}),
}));
vi.mock('@/react/hooks/useAnalytics', () => ({
useAnalytics: vi.fn().mockReturnValue({
trackEvent: vi.fn(),
}),
}));
// Sample test data // Sample test data
const mockChart: Chart = { const mockChart: Chart = {
name: 'test-chart', name: 'test-chart',
@ -58,22 +26,15 @@ const mockRouterStateService = {
go: vi.fn(), go: vi.fn(),
}; };
function renderComponent({ function renderComponent({ selectedChart = mockChart, isAdmin = true } = {}) {
selectedChart = mockChart, const user = new UserViewModel({ Username: 'user', Role: isAdmin ? 1 : 2 });
clearHelmChart = clearHelmChartMock,
namespace = 'test-namespace',
name = 'test-name',
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider( const Wrapped = withTestQueryProvider(
withUserProvider( withUserProvider(
withTestRouter(() => ( withTestRouter(() => (
<HelmTemplatesSelectedItem <HelmTemplatesSelectedItem
selectedChart={selectedChart} selectedChart={selectedChart}
clearHelmChart={clearHelmChart} clearHelmChart={clearHelmChartMock}
namespace={namespace}
name={name}
/> />
)), )),
user user
@ -101,45 +62,4 @@ describe('HelmTemplatesSelectedItem', () => {
expect(screen.getByText('Clear selection')).toBeInTheDocument(); expect(screen.getByText('Clear selection')).toBeInTheDocument();
expect(screen.getByText('https://example.com')).toBeInTheDocument(); expect(screen.getByText('https://example.com')).toBeInTheDocument();
}); });
it('should toggle custom values editor', async () => {
renderComponent();
const user = userEvent.setup();
// Verify editor is visible by default
expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
// Now hide the editor
await user.click(await screen.findByText('Custom values'));
// Editor should be hidden
expect(
screen.queryByTestId('helm-app-creation-editor')
).not.toBeInTheDocument();
});
it('should install helm chart and navigate when install button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
// Click install button
await user.click(screen.getByText('Install'));
// Check mutate was called with correct values
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
Name: 'test-name',
Repo: 'https://example.com',
Chart: 'test-chart',
Values: 'test-values',
Namespace: 'test-namespace',
}),
expect.objectContaining({ onSuccess: expect.any(Function) })
);
});
it('should disable install button when namespace or name is undefined', () => {
renderComponent({ namespace: '' });
expect(screen.getByText('Install')).toBeDisabled();
});
}); });

View file

@ -1,183 +1,58 @@
import { useRef } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Form, Formik, FormikProps } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { useCanExit } from '@/react/hooks/useCanExit';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
import { Button } from '@@/buttons/Button'; import { Button } from '@@/buttons/Button';
import { FallbackImage } from '@@/FallbackImage'; import { FallbackImage } from '@@/FallbackImage';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import { WebEditorForm } from '@@/WebEditorForm';
import { confirmGenericDiscard } from '@@/modals/confirm';
import { FormSection } from '@@/form-components/FormSection';
import { InlineLoader } from '@@/InlineLoader';
import { FormActions } from '@@/form-components/FormActions';
import { Chart } from '../types'; import { Chart } from '../types';
import { useHelmChartValues } from './queries/useHelmChartValues';
import { HelmIcon } from './HelmIcon'; import { HelmIcon } from './HelmIcon';
import { useHelmChartInstall } from './queries/useHelmChartInstall';
type Props = { type Props = {
selectedChart: Chart; selectedChart: Chart;
clearHelmChart: () => void; clearHelmChart: () => void;
namespace?: string;
name?: string;
};
type FormValues = {
values: string;
};
const emptyValues: FormValues = {
values: '',
}; };
export function HelmTemplatesSelectedItem({ export function HelmTemplatesSelectedItem({
selectedChart, selectedChart,
clearHelmChart, clearHelmChart,
namespace,
name,
}: Props) { }: Props) {
const router = useRouter();
const analytics = useAnalytics();
const { mutate: installHelmChart, isLoading: isInstalling } =
useHelmChartInstall();
const { data: initialValues, isLoading: loadingValues } =
useHelmChartValues(selectedChart);
const formikRef = useRef<FormikProps<FormValues>>(null);
useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
function handleSubmit(values: FormValues) {
if (!name || !namespace) {
// Theoretically this should never happen and is mainly to keep typescript happy
return;
}
installHelmChart(
{
Name: name,
Repo: selectedChart.repo,
Chart: selectedChart.name,
Values: values.values,
Namespace: namespace,
},
{
onSuccess() {
analytics.trackEvent('kubernetes-helm-install', {
category: 'kubernetes',
metadata: {
'chart-name': selectedChart.name,
},
});
notifySuccess('Success', 'Helm chart successfully installed');
// Reset the form so page can be navigated away from without getting "Are you sure?"
formikRef.current?.resetForm();
router.stateService.go('kubernetes.applications');
},
}
);
}
return ( return (
<> <Widget>
<Widget> <div className="flex">
<div className="flex"> <div className="basis-3/4 rounded-lg m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
<div className="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white"> <div className="vertical-center p-5">
<div className="vertical-center p-5"> <FallbackImage
<FallbackImage src={selectedChart.icon}
src={selectedChart.icon} fallbackIcon={HelmIcon}
fallbackIcon={HelmIcon} className="h-16 w-16"
className="h-16 w-16" />
/> <div className="col-sm-12">
<div className="col-sm-12"> <div>
<div> <div className="text-2xl font-bold">{selectedChart.name}</div>
<div className="text-2xl font-bold">{selectedChart.name}</div> <div className="small text-muted mt-1">
<div className="small text-muted mt-1"> {selectedChart.repo}
{selectedChart.repo}
</div>
</div> </div>
<div className="text-xs mt-2">{selectedChart.description}</div>
</div> </div>
</div> <div className="text-xs mt-2">{selectedChart.description}</div>
</div>
<div className="basis-1/4">
<div className="h-full w-full vertical-center justify-end pr-5">
<Button
color="link"
className="!text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white"
onClick={clearHelmChart}
data-cy="clear-selection"
>
Clear selection
<Icon icon={X} className="ml-1" />
</Button>
</div> </div>
</div> </div>
</div> </div>
</Widget> <div className="basis-1/4">
<Formik <div className="h-full w-full vertical-center justify-end pr-5">
innerRef={formikRef} <Button
initialValues={initialValues ?? emptyValues} color="link"
enableReinitialize className="!text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white"
onSubmit={(values) => handleSubmit(values)} onClick={clearHelmChart}
> data-cy="clear-selection"
{({ values, setFieldValue }) => ( >
<Form className="form-horizontal"> Clear selection
<div className="form-group !m-0"> <Icon icon={X} className="ml-1" />
<FormSection </Button>
title="Custom values" </div>
isFoldable </div>
defaultFolded={false} </div>
className="mt-4" </Widget>
>
{loadingValues && (
<div className="col-sm-12 p-0">
<InlineLoader>Loading values.yaml...</InlineLoader>
</div>
)}
{!!initialValues && (
<WebEditorForm
id="helm-app-creation-editor"
value={values.values}
onChange={(value) => setFieldValue('values', value)}
type="yaml"
data-cy="helm-app-creation-editor"
textTip="Define or paste the content of your values yaml file here"
>
You can get more information about Helm values file format
in the{' '}
<a
href="https://helm.sh/docs/chart_template_guide/values_files/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</WebEditorForm>
)}
</FormSection>
</div>
<FormActions
submitLabel="Install"
loadingText="Installing Helm chart"
isLoading={isInstalling}
isValid={!!namespace && !!name && !loadingValues}
data-cy="helm-install"
/>
</Form>
)}
</Formik>
</>
); );
} }

View file

@ -1,40 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
queryClient,
withGlobalError,
withInvalidate,
} from '@/react-tools/react-query';
import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys';
import { InstallChartPayload } from '../../types';
async function installHelmChart(
payload: InstallChartPayload,
environmentId: EnvironmentId
) {
try {
const response = await axios.post(
`endpoints/${environmentId}/kubernetes/helm`,
payload
);
return response.data;
} catch (err) {
throw parseAxiosError(err as Error, 'Installation error');
}
}
export function useHelmChartInstall() {
const environmentId = useEnvironmentId();
return useMutation(
(values: InstallChartPayload) => installHelmChart(values, environmentId),
{
...withGlobalError('Unable to install Helm chart'),
...withInvalidate(queryClient, [queryKeys.applications(environmentId)]),
}
);
}

View file

@ -0,0 +1,4 @@
export type HelmInstallFormValues = {
values: string;
version: string;
};

View file

@ -0,0 +1,80 @@
import { FormControl } from '@@/form-components/FormControl';
import { CodeEditor } from '@@/CodeEditor';
import { ShortcutsTooltip } from '@@/CodeEditor/ShortcutsTooltip';
type Props = {
values: string;
setValues: (values: string) => void;
valuesRef: string;
isValuesRefLoading: boolean;
};
export function HelmValuesInput({
values,
setValues,
valuesRef,
isValuesRefLoading,
}: Props) {
return (
<div className="grid grid-cols-2 gap-4">
<FormControl
label="User-defined values"
inputId="user-values-editor"
size="vertical"
className="[&>label]:!mb-1 !mx-0"
tooltip={
<>
User-defined values will override the default chart values.
<br />
You can get more information about the Helm values file format in
the{' '}
<a
href="https://helm.sh/docs/chart_template_guide/values_files/"
target="_blank"
data-cy="helm-values-reference-link"
rel="noreferrer"
>
official documentation
</a>
.
</>
}
>
<CodeEditor
id="user-values-editor"
value={values}
onChange={setValues}
height="50vh"
type="yaml"
data-cy="helm-user-values-editor"
placeholder="Define or paste the content of your values yaml file here"
showToolbar={false}
/>
</FormControl>
<FormControl
label={
<div className="flex justify-between w-full">
Values reference (read-only)
<ShortcutsTooltip />
</div>
}
inputId="values-reference"
size="vertical"
isLoading={isValuesRefLoading}
loadingText="Loading values..."
className="[&>label]:w-full [&>label]:!mb-1 !mx-0"
>
<CodeEditor
id="values-reference"
value={valuesRef}
height="50vh"
type="yaml"
readonly
data-cy="helm-values-reference"
placeholder="No values reference found"
showToolbar={false}
/>
</FormControl>
</div>
);
}

View file

@ -3,15 +3,16 @@ 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 { Chart } from '../../types'; type Params = {
chart: string;
repo: string;
version?: string;
};
async function getHelmChartValues(chart: string, repo: string) { 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`, {
params: { params,
repo,
chart,
},
}); });
return response.data; return response.data;
} catch (err) { } catch (err) {
@ -19,14 +20,15 @@ async function getHelmChartValues(chart: string, repo: string) {
} }
} }
export function useHelmChartValues(chart: Chart) { export function useHelmChartValues(params: Params) {
return useQuery({ return useQuery({
queryKey: ['helm-chart-values', chart.repo, chart.name], queryKey: ['helm-chart-values', params.repo, params.chart, params.version],
queryFn: () => getHelmChartValues(chart.name, chart.repo), queryFn: () => getHelmChartValues(params),
enabled: !!chart.name, enabled: !!params.chart && !!params.repo,
select: (data) => ({ select: (data) => ({
values: data, values: data,
}), }),
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
...withGlobalError('Unable to get Helm chart values'), ...withGlobalError('Unable to get Helm chart values'),
}); });
} }

View file

@ -6,7 +6,7 @@ import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { useCurrentUser } from '@/react/hooks/useUser'; import { useCurrentUser } from '@/react/hooks/useUser';
import { getHelmRepositories } from '../../queries/useHelmChartList'; import { getHelmRepositories } from './useHelmChartList';
interface HelmSearch { interface HelmSearch {
entries: Entries; entries: Entries;

View file

@ -5,17 +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 { HelmRelease } from '../../types'; import { HelmRelease, UpdateHelmReleasePayload } from '../types';
export interface UpdateHelmReleasePayload {
namespace: string;
values?: string;
repo?: string;
name: string;
chart: string;
version?: string;
atomic?: boolean;
}
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) { export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({

View file

@ -112,4 +112,15 @@ export interface InstallChartPayload {
Chart: string; Chart: string;
Values: string; Values: string;
Namespace: string; Namespace: string;
Version?: string;
}
export interface UpdateHelmReleasePayload {
namespace: string;
values?: string;
repo?: string;
name: string;
chart: string;
version?: string;
atomic?: boolean;
} }

View file

@ -19,6 +19,7 @@ type ShowOptions struct {
OutputFormat ShowOutputFormat OutputFormat ShowOutputFormat
Chart string Chart string
Repo string Repo string
Version string
Env []string Env []string
} }

View file

@ -105,7 +105,7 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) { func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig) showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
showClient.ChartPathOptions.RepoURL = showOpts.Repo showClient.ChartPathOptions.RepoURL = showOpts.Repo
showClient.ChartPathOptions.Version = "" // Latest version showClient.ChartPathOptions.Version = showOpts.Version // If version is "", it will use the latest version
// Set output type based on ShowOptions // Set output type based on ShowOptions
switch showOpts.OutputFormat { switch showOpts.OutputFormat {