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:
parent
caac45b834
commit
a9061e5258
29 changed files with 864 additions and 562 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -235,6 +235,7 @@ export const ngModule = angular
|
||||||
'schema',
|
'schema',
|
||||||
'fileName',
|
'fileName',
|
||||||
'placeholder',
|
'placeholder',
|
||||||
|
'showToolbar',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
52
app/react/components/CodeEditor/ShortcutsTooltip.tsx
Normal file
52
app/react/components/CodeEditor/ShortcutsTooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
32
app/react/components/ExternalLink.tsx
Normal file
32
app/react/components/ExternalLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
|
@ -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 ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
174
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx
Normal file
174
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
106
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx
Normal file
106
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx
Normal 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');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)]),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
4
app/react/kubernetes/helm/HelmTemplates/types.ts
Normal file
4
app/react/kubernetes/helm/HelmTemplates/types.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export type HelmInstallFormValues = {
|
||||||
|
values: string;
|
||||||
|
version: string;
|
||||||
|
};
|
80
app/react/kubernetes/helm/components/HelmValuesInput.tsx
Normal file
80
app/react/kubernetes/helm/components/HelmValuesInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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'),
|
||||||
});
|
});
|
||||||
}
|
}
|
|
@ -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;
|
|
@ -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({
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue