mirror of
https://github.com/portainer/portainer.git
synced 2025-07-18 21:09:40 +02:00
feat(helm): helm actions [r8s-259] (#715)
Co-authored-by: James Player <james.player@portainer.io> Co-authored-by: Cara Ryan <cara.ryan@portainer.io> Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
parent
dfa32b6755
commit
4ee349bd6b
117 changed files with 4161 additions and 696 deletions
|
@ -26,6 +26,7 @@ type installChartPayload struct {
|
||||||
Chart string `json:"chart"`
|
Chart string `json:"chart"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
Values string `json:"values"`
|
Values string `json:"values"`
|
||||||
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var errChartNameInvalid = errors.New("invalid chart name. " +
|
var errChartNameInvalid = errors.New("invalid chart name. " +
|
||||||
|
@ -101,6 +102,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
||||||
installOpts := options.InstallOptions{
|
installOpts := options.InstallOptions{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Chart: p.Chart,
|
Chart: p.Chart,
|
||||||
|
Version: p.Version,
|
||||||
Namespace: p.Namespace,
|
Namespace: p.Namespace,
|
||||||
Repo: p.Repo,
|
Repo: p.Repo,
|
||||||
KubernetesClusterAccess: clusterAccess,
|
KubernetesClusterAccess: clusterAccess,
|
||||||
|
@ -192,7 +194,7 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte,
|
||||||
g := new(errgroup.Group)
|
g := new(errgroup.Group)
|
||||||
for _, resource := range yamlResources {
|
for _, resource := range yamlResources {
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
tmpfile, err := os.CreateTemp("", "helm-manifest-*")
|
tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to create a tmp helm manifest file")
|
return errors.Wrap(err, "failed to create a tmp helm manifest file")
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,10 @@ body {
|
||||||
color: var(--text-body-color) !important;
|
color: var(--text-body-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-widget-color {
|
||||||
|
background: var(--bg-widget-color);
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#page-wrapper,
|
#page-wrapper,
|
||||||
|
@ -224,7 +228,7 @@ input[type='checkbox'] {
|
||||||
|
|
||||||
.blocklist-item--selected {
|
.blocklist-item--selected {
|
||||||
background-color: var(--bg-blocklist-item-selected-color);
|
background-color: var(--bg-blocklist-item-selected-color);
|
||||||
border: 2px solid var(--border-blocklist-item-selected-color);
|
border-color: var(--border-blocklist-item-selected-color);
|
||||||
color: var(--text-blocklist-item-selected-color);
|
color: var(--text-blocklist-item-selected-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
4
app/assets/css/bootstrap-override.css
vendored
4
app/assets/css/bootstrap-override.css
vendored
|
@ -20,9 +20,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical-center {
|
.vertical-center {
|
||||||
display: inline-flex;
|
@apply inline-flex items-center gap-1;
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-center {
|
.flex-center {
|
||||||
|
|
|
@ -268,7 +268,7 @@
|
||||||
--bg-body-color: var(--grey-2);
|
--bg-body-color: var(--grey-2);
|
||||||
--bg-btn-default-color: var(--grey-3);
|
--bg-btn-default-color: var(--grey-3);
|
||||||
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
|
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
|
||||||
--bg-blocklist-item-selected-color: var(--grey-3);
|
--bg-blocklist-item-selected-color: var(--ui-gray-iron-10);
|
||||||
--bg-card-color: var(--grey-1);
|
--bg-card-color: var(--grey-1);
|
||||||
--bg-checkbox-border-color: var(--grey-8);
|
--bg-checkbox-border-color: var(--grey-8);
|
||||||
--bg-code-color: var(--grey-2);
|
--bg-code-color: var(--grey-2);
|
||||||
|
@ -388,7 +388,7 @@
|
||||||
--border-navtabs-color: var(--grey-38);
|
--border-navtabs-color: var(--grey-38);
|
||||||
--border-pre-color: var(--grey-3);
|
--border-pre-color: var(--grey-3);
|
||||||
--border-blocklist: var(--ui-gray-9);
|
--border-blocklist: var(--ui-gray-9);
|
||||||
--border-blocklist-item-selected-color: var(--grey-38);
|
--border-blocklist-item-selected-color: var(--grey-31);
|
||||||
--border-pagination-span-color: var(--grey-1);
|
--border-pagination-span-color: var(--grey-1);
|
||||||
--border-pagination-hover-color: var(--grey-3);
|
--border-pagination-hover-color: var(--grey-3);
|
||||||
--border-panel-color: var(--grey-2);
|
--border-panel-color: var(--grey-2);
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
<div class="col-sm-12" ng-if="ctrl.formValues.displayCodeEditor">
|
<div class="col-sm-12" ng-if="ctrl.formValues.displayCodeEditor">
|
||||||
<web-editor-form
|
<web-editor-form
|
||||||
identifier="config-creation-editor"
|
identifier="config-creation-editor"
|
||||||
placeholder="Define or paste the content of your config here"
|
text-tip="Define or paste the content of your config here"
|
||||||
yml="false"
|
yml="false"
|
||||||
on-change="(ctrl.editorUpdate)"
|
on-change="(ctrl.editorUpdate)"
|
||||||
value="ctrl.formValues.ConfigContent"
|
value="ctrl.formValues.ConfigContent"
|
||||||
|
|
|
@ -94,7 +94,7 @@
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<code-editor
|
<code-editor
|
||||||
identifier="image-build-editor"
|
identifier="image-build-editor"
|
||||||
placeholder="Define or paste the content of your Dockerfile here"
|
text-tip="Define or paste the content of your Dockerfile here"
|
||||||
docker-file="true"
|
docker-file="true"
|
||||||
on-change="(editorUpdate)"
|
on-change="(editorUpdate)"
|
||||||
></code-editor>
|
></code-editor>
|
||||||
|
|
|
@ -145,7 +145,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
|
|
||||||
const helmApplication = {
|
const helmApplication = {
|
||||||
name: 'kubernetes.helm',
|
name: 'kubernetes.helm',
|
||||||
url: '/helm/:namespace/:name',
|
url: '/helm/:namespace/:name?revision&tab',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'kubernetesHelmApplicationView',
|
component: 'kubernetesHelmApplicationView',
|
||||||
|
|
|
@ -165,7 +165,7 @@
|
||||||
value="$ctrl.formValues.DataYaml"
|
value="$ctrl.formValues.DataYaml"
|
||||||
on-change="($ctrl.editorUpdate)"
|
on-change="($ctrl.editorUpdate)"
|
||||||
yml="true"
|
yml="true"
|
||||||
placeholder="Define or paste key-value pairs, one pair per line"
|
text-tip="Define or paste key-value pairs, one pair per line"
|
||||||
>
|
>
|
||||||
</web-editor-form>
|
</web-editor-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<react-code-editor
|
<react-code-editor
|
||||||
id="$ctrl.identifier"
|
id="$ctrl.identifier"
|
||||||
placeholder="$ctrl.placeholder"
|
text-tip="$ctrl.textTip"
|
||||||
type="$ctrl.type"
|
type="$ctrl.type"
|
||||||
readonly="$ctrl.readOnly"
|
readonly="$ctrl.readOnly"
|
||||||
on-change="($ctrl.handleChange)"
|
on-change="($ctrl.handleChange)"
|
||||||
|
|
|
@ -5,7 +5,7 @@ angular.module('portainer.app').component('codeEditor', {
|
||||||
controller,
|
controller,
|
||||||
bindings: {
|
bindings: {
|
||||||
identifier: '@',
|
identifier: '@',
|
||||||
placeholder: '@',
|
textTip: '@',
|
||||||
yml: '<',
|
yml: '<',
|
||||||
dockerFile: '<',
|
dockerFile: '<',
|
||||||
shell: '<',
|
shell: '<',
|
||||||
|
|
|
@ -6,7 +6,7 @@ export const webEditorForm = {
|
||||||
|
|
||||||
bindings: {
|
bindings: {
|
||||||
identifier: '@',
|
identifier: '@',
|
||||||
placeholder: '@',
|
textTip: '@',
|
||||||
yml: '<',
|
yml: '<',
|
||||||
value: '<',
|
value: '<',
|
||||||
readOnly: '<',
|
readOnly: '<',
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
<div class="col-sm-12 col-lg-12">
|
<div class="col-sm-12 col-lg-12">
|
||||||
<code-editor
|
<code-editor
|
||||||
identifier="{{ $ctrl.identifier }}"
|
identifier="{{ $ctrl.identifier }}"
|
||||||
placeholder="{{ $ctrl.placeholder }}"
|
text-tip="{{ $ctrl.textTip }}"
|
||||||
read-only="$ctrl.readOnly"
|
read-only="$ctrl.readOnly"
|
||||||
yml="$ctrl.yml"
|
yml="$ctrl.yml"
|
||||||
value="$ctrl.value"
|
value="$ctrl.value"
|
||||||
|
|
|
@ -223,7 +223,7 @@ export const ngModule = angular
|
||||||
'reactCodeEditor',
|
'reactCodeEditor',
|
||||||
r2a(CodeEditor, [
|
r2a(CodeEditor, [
|
||||||
'id',
|
'id',
|
||||||
'placeholder',
|
'textTip',
|
||||||
'type',
|
'type',
|
||||||
'readonly',
|
'readonly',
|
||||||
'onChange',
|
'onChange',
|
||||||
|
@ -233,6 +233,8 @@ export const ngModule = angular
|
||||||
'versions',
|
'versions',
|
||||||
'onVersionChange',
|
'onVersionChange',
|
||||||
'schema',
|
'schema',
|
||||||
|
'fileName',
|
||||||
|
'placeholder',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
|
|
|
@ -128,7 +128,7 @@
|
||||||
on-change="(onChangeFileContent)"
|
on-change="(onChangeFileContent)"
|
||||||
ng-required="true"
|
ng-required="true"
|
||||||
yml="true"
|
yml="true"
|
||||||
placeholder="Define or paste the content of your docker compose file here"
|
text-tip="Define or paste the content of your docker compose file here"
|
||||||
read-only="state.isEditorReadOnly"
|
read-only="state.isEditorReadOnly"
|
||||||
schema="dockerComposeSchema"
|
schema="dockerComposeSchema"
|
||||||
>
|
>
|
||||||
|
|
|
@ -160,7 +160,7 @@
|
||||||
<code-editor
|
<code-editor
|
||||||
read-only="orphaned"
|
read-only="orphaned"
|
||||||
identifier="stack-editor"
|
identifier="stack-editor"
|
||||||
placeholder="Define or paste the content of your docker compose file here"
|
text-tip="Define or paste the content of your docker compose file here"
|
||||||
yml="true"
|
yml="true"
|
||||||
on-change="(editorUpdate)"
|
on-change="(editorUpdate)"
|
||||||
value="stackFileContent"
|
value="stackFileContent"
|
||||||
|
|
16
app/react/common/date-utils.ts
Normal file
16
app/react/common/date-utils.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Format a date to a human-readable string based on the user's locale.
|
||||||
|
*/
|
||||||
|
export function localizeDate(date: Date) {
|
||||||
|
return date
|
||||||
|
.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
.replace('am', 'AM')
|
||||||
|
.replace('pm', 'PM');
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ export default {
|
||||||
'dangerSecondary',
|
'dangerSecondary',
|
||||||
'warnSecondary',
|
'warnSecondary',
|
||||||
'infoSecondary',
|
'infoSecondary',
|
||||||
|
'muted',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -35,6 +36,7 @@ function Template({ type = 'success' }: Props) {
|
||||||
dangerSecondary: 'dangerSecondary badge',
|
dangerSecondary: 'dangerSecondary badge',
|
||||||
warnSecondary: 'warnSecondary badge',
|
warnSecondary: 'warnSecondary badge',
|
||||||
infoSecondary: 'infoSecondary badge',
|
infoSecondary: 'infoSecondary badge',
|
||||||
|
muted: 'muted badge',
|
||||||
};
|
};
|
||||||
return <Badge type={type}>{message[type]}</Badge>;
|
return <Badge type={type}>{message[type]}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,8 @@ export type BadgeType =
|
||||||
| 'successSecondary'
|
| 'successSecondary'
|
||||||
| 'dangerSecondary'
|
| 'dangerSecondary'
|
||||||
| 'warnSecondary'
|
| 'warnSecondary'
|
||||||
| 'infoSecondary';
|
| 'infoSecondary'
|
||||||
|
| 'muted';
|
||||||
|
|
||||||
// the classes are typed in full because tailwind doesn't render the interpolated classes
|
// the classes are typed in full because tailwind doesn't render the interpolated classes
|
||||||
const typeClasses: Record<BadgeType, string> = {
|
const typeClasses: Record<BadgeType, string> = {
|
||||||
|
@ -54,6 +55,11 @@ const typeClasses: Record<BadgeType, string> = {
|
||||||
'th-dark:text-blue-3 th-dark:bg-blue-9',
|
'th-dark:text-blue-3 th-dark:bg-blue-9',
|
||||||
'th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9'
|
'th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9'
|
||||||
),
|
),
|
||||||
|
muted: clsx(
|
||||||
|
'text-gray-9 bg-gray-3',
|
||||||
|
'th-dark:text-gray-3 th-dark:bg-gray-9',
|
||||||
|
'th-highcontrast:text-gray-3 th-highcontrast:bg-gray-9'
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
|
75
app/react/components/Blocklist/BlocklistItem.stories.tsx
Normal file
75
app/react/components/Blocklist/BlocklistItem.stories.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { localizeDate } from '@/react/common/date-utils';
|
||||||
|
|
||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
|
||||||
|
import { BlocklistItem } from './BlocklistItem';
|
||||||
|
|
||||||
|
const meta: Meta<typeof BlocklistItem> = {
|
||||||
|
title: 'Components/Blocklist/BlocklistItem',
|
||||||
|
component: BlocklistItem,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="blocklist">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof BlocklistItem>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Default Blocklist Item',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Selected: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Selected Blocklist Item',
|
||||||
|
isSelected: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AsDiv: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Blocklist Item as div',
|
||||||
|
as: 'div',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithCustomContent: Story = {
|
||||||
|
args: {
|
||||||
|
children: (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="flex flex-wrap gap-1 justify-between">
|
||||||
|
<Badge type="success">Deployed</Badge>
|
||||||
|
<span className="text-xs text-muted">Revision #4</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 justify-between">
|
||||||
|
<span className="text-xs text-muted">my-app-1.0.0</span>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{localizeDate(new Date('2000-01-01'))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleItems: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="blocklist">
|
||||||
|
<BlocklistItem>First Item</BlocklistItem>
|
||||||
|
<BlocklistItem isSelected>Second Item (Selected)</BlocklistItem>
|
||||||
|
<BlocklistItem>Third Item</BlocklistItem>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
|
@ -10,7 +10,7 @@ export function Card({ className, children }: PropsWithChildren<Props>) {
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
className,
|
||||||
'rounded border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
|
'rounded-lg border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -1,228 +0,0 @@
|
||||||
import CodeMirror, {
|
|
||||||
keymap,
|
|
||||||
oneDarkHighlightStyle,
|
|
||||||
} from '@uiw/react-codemirror';
|
|
||||||
import {
|
|
||||||
StreamLanguage,
|
|
||||||
LanguageSupport,
|
|
||||||
syntaxHighlighting,
|
|
||||||
indentService,
|
|
||||||
} from '@codemirror/language';
|
|
||||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
|
||||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
|
||||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { createTheme } from '@uiw/codemirror-themes';
|
|
||||||
import { tags as highlightTags } from '@lezer/highlight';
|
|
||||||
import type { JSONSchema7 } from 'json-schema';
|
|
||||||
import { lintKeymap, lintGutter } from '@codemirror/lint';
|
|
||||||
import { defaultKeymap } from '@codemirror/commands';
|
|
||||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
|
||||||
import { yamlCompletion, yamlSchema } from 'yaml-schema';
|
|
||||||
|
|
||||||
import { AutomationTestingProps } from '@/types';
|
|
||||||
|
|
||||||
import { CopyButton } from '@@/buttons/CopyButton';
|
|
||||||
|
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
|
||||||
|
|
||||||
import styles from './CodeEditor.module.css';
|
|
||||||
import { TextTip } from './Tip/TextTip';
|
|
||||||
import { StackVersionSelector } from './StackVersionSelector';
|
|
||||||
|
|
||||||
type Type = 'yaml' | 'shell' | 'dockerfile';
|
|
||||||
interface Props extends AutomationTestingProps {
|
|
||||||
id: string;
|
|
||||||
placeholder?: string;
|
|
||||||
type?: Type;
|
|
||||||
readonly?: boolean;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
value: string;
|
|
||||||
height?: string;
|
|
||||||
versions?: number[];
|
|
||||||
onVersionChange?: (version: number) => void;
|
|
||||||
schema?: JSONSchema7;
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme = createTheme({
|
|
||||||
theme: 'light',
|
|
||||||
settings: {
|
|
||||||
background: 'var(--bg-codemirror-color)',
|
|
||||||
foreground: 'var(--text-codemirror-color)',
|
|
||||||
caret: 'var(--border-codemirror-cursor-color)',
|
|
||||||
selection: 'var(--bg-codemirror-selected-color)',
|
|
||||||
selectionMatch: 'var(--bg-codemirror-selected-color)',
|
|
||||||
gutterBackground: 'var(--bg-codemirror-gutters-color)',
|
|
||||||
},
|
|
||||||
styles: [
|
|
||||||
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
|
|
||||||
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
|
|
||||||
{
|
|
||||||
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
|
|
||||||
color: 'var(--text-cm-string-color)',
|
|
||||||
},
|
|
||||||
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
|
|
||||||
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
|
|
||||||
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
|
|
||||||
{
|
|
||||||
tag: highlightTags.variableName,
|
|
||||||
color: 'var(--text-cm-variable-name-color)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Custom indentation service for YAML
|
|
||||||
const yamlIndentExtension = indentService.of((context, pos) => {
|
|
||||||
const prevLine = context.lineAt(pos, -1);
|
|
||||||
|
|
||||||
// Default to same as previous line
|
|
||||||
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
|
|
||||||
|
|
||||||
// If previous line ends with a colon, increase indent
|
|
||||||
if (/:\s*$/.test(prevLine.text)) {
|
|
||||||
return prevIndent + 2; // Indent 2 spaces after a colon
|
|
||||||
}
|
|
||||||
|
|
||||||
return prevIndent;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create enhanced YAML language with custom indentation (from @codemirror/legacy-modes/mode/yaml)
|
|
||||||
const yamlLanguageLegacy = new LanguageSupport(StreamLanguage.define(yaml), [
|
|
||||||
yamlIndentExtension,
|
|
||||||
syntaxHighlighting(oneDarkHighlightStyle),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const dockerFileLanguage = new LanguageSupport(
|
|
||||||
StreamLanguage.define(dockerFile)
|
|
||||||
);
|
|
||||||
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
|
|
||||||
|
|
||||||
const docTypeExtensionMap: Record<Type, LanguageSupport> = {
|
|
||||||
yaml: yamlLanguageLegacy,
|
|
||||||
dockerfile: dockerFileLanguage,
|
|
||||||
shell: shellLanguage,
|
|
||||||
};
|
|
||||||
|
|
||||||
function schemaValidationExtensions(schema: JSONSchema7) {
|
|
||||||
// skip the hover extension because fields like 'networks' display as 'null' with no description when using the default hover
|
|
||||||
// skip the completion extension in favor of custom completion
|
|
||||||
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
|
|
||||||
return [
|
|
||||||
yaml,
|
|
||||||
linter,
|
|
||||||
autocompletion({
|
|
||||||
icons: false,
|
|
||||||
activateOnTypingDelay: 300,
|
|
||||||
selectOnOpen: true,
|
|
||||||
activateOnTyping: true,
|
|
||||||
override: [
|
|
||||||
(ctx) => {
|
|
||||||
const getCompletions = yamlCompletion();
|
|
||||||
const completions = getCompletions(ctx);
|
|
||||||
if (Array.isArray(completions)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
completions.validFor = /^\w*$/;
|
|
||||||
|
|
||||||
return completions;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
stateExtensions,
|
|
||||||
yamlIndentExtension,
|
|
||||||
syntaxHighlighting(oneDarkHighlightStyle),
|
|
||||||
lintGutter(),
|
|
||||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodeEditor({
|
|
||||||
id,
|
|
||||||
onChange = () => {},
|
|
||||||
placeholder,
|
|
||||||
readonly,
|
|
||||||
value,
|
|
||||||
versions,
|
|
||||||
onVersionChange,
|
|
||||||
height = '500px',
|
|
||||||
type,
|
|
||||||
schema,
|
|
||||||
'data-cy': dataCy,
|
|
||||||
}: Props) {
|
|
||||||
const [isRollback, setIsRollback] = useState(false);
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
|
||||||
if (!type || !docTypeExtensionMap[type]) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// YAML-specific schema validation
|
|
||||||
if (schema && type === 'yaml') {
|
|
||||||
return schemaValidationExtensions(schema);
|
|
||||||
}
|
|
||||||
// Default language support
|
|
||||||
return [docTypeExtensionMap[type]];
|
|
||||||
}, [type, schema]);
|
|
||||||
|
|
||||||
const handleVersionChange = useCallback(
|
|
||||||
(version: number) => {
|
|
||||||
if (versions && versions.length > 1) {
|
|
||||||
setIsRollback(version < versions[0]);
|
|
||||||
}
|
|
||||||
onVersionChange?.(version);
|
|
||||||
},
|
|
||||||
[onVersionChange, versions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mb-2 flex flex-col">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
{!!placeholder && <TextTip color="blue">{placeholder}</TextTip>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 to clipboard
|
|
||||||
</CopyButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{versions && (
|
|
||||||
<div className="mt-2 flex">
|
|
||||||
<div className="ml-auto mr-2">
|
|
||||||
<StackVersionSelector
|
|
||||||
versions={versions}
|
|
||||||
onChange={handleVersionChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CodeMirror
|
|
||||||
className={styles.root}
|
|
||||||
theme={theme}
|
|
||||||
value={debouncedValue}
|
|
||||||
onChange={debouncedOnChange}
|
|
||||||
readOnly={readonly || isRollback}
|
|
||||||
id={id}
|
|
||||||
extensions={extensions}
|
|
||||||
height={height}
|
|
||||||
basicSetup={{
|
|
||||||
highlightSelectionMatches: false,
|
|
||||||
autocompletion: !!schema,
|
|
||||||
}}
|
|
||||||
data-cy={dataCy}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -47,17 +47,33 @@
|
||||||
|
|
||||||
.root :global(.cm-editor .cm-gutters) {
|
.root :global(.cm-editor .cm-gutters) {
|
||||||
border-right: 0px;
|
border-right: 0px;
|
||||||
|
@apply bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.cm-merge-b) {
|
||||||
|
@apply border-0 border-l border-solid border-l-gray-5 th-dark:border-l-gray-7 th-highcontrast:border-l-gray-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) {
|
.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.cm-editor),
|
.codeEditor :global(.cm-editor),
|
||||||
.root :global(.cm-editor .cm-scroller) {
|
.codeEditor :global(.cm-editor .cm-scroller) {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* code mirror merge side-by-side editor */
|
||||||
|
.root :global(.cm-merge-a),
|
||||||
|
.root :global(.cm-merge-a .cm-scroller) {
|
||||||
|
@apply !rounded-r-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.cm-merge-b),
|
||||||
|
.root :global(.cm-merge-b .cm-scroller) {
|
||||||
|
@apply !rounded-l-none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Search Panel */
|
/* Search Panel */
|
||||||
/* Ideally we would use a react component for that, but this is the easy solution for onw */
|
/* Ideally we would use a react component for that, but this is the easy solution for onw */
|
||||||
|
|
||||||
|
@ -162,3 +178,29 @@
|
||||||
.root :global(.cm-activeLineGutter) {
|
.root :global(.cm-activeLineGutter) {
|
||||||
@apply bg-inherit;
|
@apply bg-inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed lines gutter styles for all themes */
|
||||||
|
.root :global(.cm-editor .cm-collapsedLines) {
|
||||||
|
/* inherit bg, instead of using styles from library */
|
||||||
|
background: inherit;
|
||||||
|
@apply bg-blue-2 th-dark:bg-blue-10 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
|
||||||
|
}
|
||||||
|
.root :global(.cm-editor .cm-collapsedLines):hover {
|
||||||
|
@apply bg-blue-3 th-dark:bg-blue-9 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.cm-editor .cm-collapsedLines:before) {
|
||||||
|
content: '↧ Expand all';
|
||||||
|
background: var(--bg-tooltip-color);
|
||||||
|
color: var(--text-tooltip-color);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1000;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
/* override the default content */
|
||||||
|
.root :global(.cm-editor .cm-collapsedLines:after) {
|
||||||
|
content: '';
|
||||||
|
}
|
|
@ -1,9 +1,21 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
|
||||||
import { CodeEditor } from './CodeEditor';
|
import { CodeEditor } from './CodeEditor';
|
||||||
|
|
||||||
vi.mock('yaml-schema', () => ({}));
|
const mockExtension: Extension = { extension: [] };
|
||||||
|
vi.mock('yaml-schema', () => ({
|
||||||
|
// yamlSchema has 5 return values (all extensions)
|
||||||
|
yamlSchema: () => [
|
||||||
|
mockExtension,
|
||||||
|
mockExtension,
|
||||||
|
mockExtension,
|
||||||
|
mockExtension,
|
||||||
|
mockExtension,
|
||||||
|
],
|
||||||
|
yamlCompletion: () => () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
id: 'test-editor',
|
id: 'test-editor',
|
||||||
|
@ -24,7 +36,7 @@ test('should render with basic props', () => {
|
||||||
test('should display placeholder when provided', async () => {
|
test('should display placeholder when provided', async () => {
|
||||||
const placeholder = 'Enter your code here';
|
const placeholder = 'Enter your code here';
|
||||||
const { findByText } = render(
|
const { findByText } = render(
|
||||||
<CodeEditor {...defaultProps} placeholder={placeholder} />
|
<CodeEditor {...defaultProps} textTip={placeholder} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeholderText = await findByText(placeholder);
|
const placeholderText = await findByText(placeholder);
|
||||||
|
@ -44,7 +56,7 @@ test('should show copy button and copy content', async () => {
|
||||||
clipboard: mockClipboard,
|
clipboard: mockClipboard,
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyButton = await findByText('Copy to clipboard');
|
const copyButton = await findByText('Copy');
|
||||||
expect(copyButton).toBeVisible();
|
expect(copyButton).toBeVisible();
|
||||||
|
|
||||||
await userEvent.click(copyButton);
|
await userEvent.click(copyButton);
|
||||||
|
@ -113,3 +125,14 @@ test('should apply custom height', async () => {
|
||||||
const editor = (await findByRole('textbox')).parentElement?.parentElement;
|
const editor = (await findByRole('textbox')).parentElement?.parentElement;
|
||||||
expect(editor).toHaveStyle({ height: customHeight });
|
expect(editor).toHaveStyle({ height: customHeight });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should render with file name header when provided', async () => {
|
||||||
|
const fileName = 'example.yaml';
|
||||||
|
const testValue = 'file content';
|
||||||
|
const { findByText } = render(
|
||||||
|
<CodeEditor {...defaultProps} fileName={fileName} value={testValue} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await findByText(fileName)).toBeInTheDocument();
|
||||||
|
expect(await findByText(testValue)).toBeInTheDocument();
|
||||||
|
});
|
158
app/react/components/CodeEditor/CodeEditor.tsx
Normal file
158
app/react/components/CodeEditor/CodeEditor.tsx
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { createTheme } from '@uiw/codemirror-themes';
|
||||||
|
import { tags as highlightTags } from '@lezer/highlight';
|
||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
|
import { CopyButton } from '@@/buttons/CopyButton';
|
||||||
|
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import { TextTip } from '../Tip/TextTip';
|
||||||
|
import { StackVersionSelector } from '../StackVersionSelector';
|
||||||
|
|
||||||
|
import styles from './CodeEditor.module.css';
|
||||||
|
import {
|
||||||
|
useCodeEditorExtensions,
|
||||||
|
CodeEditorType,
|
||||||
|
} from './useCodeEditorExtensions';
|
||||||
|
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
|
||||||
|
|
||||||
|
interface Props extends AutomationTestingProps {
|
||||||
|
id: string;
|
||||||
|
textTip?: string;
|
||||||
|
type?: CodeEditorType;
|
||||||
|
readonly?: boolean;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
value: string;
|
||||||
|
height?: string;
|
||||||
|
versions?: number[];
|
||||||
|
onVersionChange?: (version: number) => void;
|
||||||
|
schema?: JSONSchema7;
|
||||||
|
fileName?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
theme: 'light',
|
||||||
|
settings: {
|
||||||
|
background: 'var(--bg-codemirror-color)',
|
||||||
|
foreground: 'var(--text-codemirror-color)',
|
||||||
|
caret: 'var(--border-codemirror-cursor-color)',
|
||||||
|
selection: 'var(--bg-codemirror-selected-color)',
|
||||||
|
selectionMatch: 'var(--bg-codemirror-selected-color)',
|
||||||
|
},
|
||||||
|
styles: [
|
||||||
|
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
|
||||||
|
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
|
||||||
|
{
|
||||||
|
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
|
||||||
|
color: 'var(--text-cm-string-color)',
|
||||||
|
},
|
||||||
|
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
|
||||||
|
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
|
||||||
|
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
|
||||||
|
{
|
||||||
|
tag: highlightTags.variableName,
|
||||||
|
color: 'var(--text-cm-variable-name-color)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CodeEditor({
|
||||||
|
id,
|
||||||
|
onChange = () => {},
|
||||||
|
textTip,
|
||||||
|
readonly,
|
||||||
|
value,
|
||||||
|
versions,
|
||||||
|
onVersionChange,
|
||||||
|
height = '500px',
|
||||||
|
type,
|
||||||
|
schema,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
fileName,
|
||||||
|
placeholder,
|
||||||
|
}: Props) {
|
||||||
|
const [isRollback, setIsRollback] = useState(false);
|
||||||
|
|
||||||
|
const extensions = useCodeEditorExtensions(type, schema);
|
||||||
|
|
||||||
|
const handleVersionChange = useCallback(
|
||||||
|
(version: number) => {
|
||||||
|
if (versions && versions.length > 1) {
|
||||||
|
setIsRollback(version < versions[0]);
|
||||||
|
}
|
||||||
|
onVersionChange?.(version);
|
||||||
|
},
|
||||||
|
[onVersionChange, versions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
|
{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">
|
||||||
|
{fileName && (
|
||||||
|
<FileNameHeaderRow>
|
||||||
|
<FileNameHeader
|
||||||
|
fileName={fileName}
|
||||||
|
copyText={value}
|
||||||
|
data-cy={`copy-code-button-${id}`}
|
||||||
|
/>
|
||||||
|
</FileNameHeaderRow>
|
||||||
|
)}
|
||||||
|
<CodeMirror
|
||||||
|
className={clsx(styles.root, styles.codeEditor)}
|
||||||
|
theme={theme}
|
||||||
|
value={debouncedValue}
|
||||||
|
onChange={debouncedOnChange}
|
||||||
|
readOnly={readonly || isRollback}
|
||||||
|
id={id}
|
||||||
|
extensions={extensions}
|
||||||
|
height={height}
|
||||||
|
basicSetup={{
|
||||||
|
highlightSelectionMatches: false,
|
||||||
|
autocompletion: !!schema,
|
||||||
|
}}
|
||||||
|
data-cy={dataCy}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
101
app/react/components/CodeEditor/DiffViewer.test.tsx
Normal file
101
app/react/components/CodeEditor/DiffViewer.test.tsx
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { DiffViewer } from './DiffViewer';
|
||||||
|
|
||||||
|
// Mock CodeMirror
|
||||||
|
vi.mock('@uiw/react-codemirror', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div data-cy="mock-editor" />,
|
||||||
|
oneDarkHighlightStyle: {},
|
||||||
|
keymap: {
|
||||||
|
of: () => ({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock react-codemirror-merge
|
||||||
|
vi.mock('react-codemirror-merge', () => {
|
||||||
|
function CodeMirrorMerge({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div data-cy="mock-code-mirror-merge">{children}</div>;
|
||||||
|
}
|
||||||
|
function Original({ value }: { value: string }) {
|
||||||
|
return <div data-cy="mock-original">{value}</div>;
|
||||||
|
}
|
||||||
|
function Modified({ value }: { value: string }) {
|
||||||
|
return <div data-cy="mock-modified">{value}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
CodeMirrorMerge.Original = Original;
|
||||||
|
CodeMirrorMerge.Modified = Modified;
|
||||||
|
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: CodeMirrorMerge,
|
||||||
|
CodeMirrorMerge,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DiffViewer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear any mocks or state before each test
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with basic props', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<DiffViewer
|
||||||
|
originalCode="Original text"
|
||||||
|
newCode="New text"
|
||||||
|
id="test-diff-viewer"
|
||||||
|
data-cy="test-diff-viewer"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the component renders with the expected content
|
||||||
|
expect(getByText('Original text')).toBeInTheDocument();
|
||||||
|
expect(getByText('New text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with file name headers when provided', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<DiffViewer
|
||||||
|
originalCode="Original text"
|
||||||
|
newCode="New text"
|
||||||
|
id="test-diff-viewer"
|
||||||
|
data-cy="test-diff-viewer"
|
||||||
|
fileNames={{
|
||||||
|
original: 'Original File',
|
||||||
|
modified: 'Modified File',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look for elements with the expected class structure
|
||||||
|
const headerOriginal = getByText('Original File');
|
||||||
|
const headerModified = getByText('Modified File');
|
||||||
|
expect(headerOriginal).toBeInTheDocument();
|
||||||
|
expect(headerModified).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom height when provided', () => {
|
||||||
|
const customHeight = '800px';
|
||||||
|
const { container } = render(
|
||||||
|
<DiffViewer
|
||||||
|
originalCode="Original text"
|
||||||
|
newCode="New text"
|
||||||
|
id="test-diff-viewer"
|
||||||
|
data-cy="test-diff-viewer"
|
||||||
|
height={customHeight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the element with the style containing the height
|
||||||
|
const divWithStyle = container.querySelector('[style*="height"]');
|
||||||
|
expect(divWithStyle).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that the style contains the expected height
|
||||||
|
expect(divWithStyle?.getAttribute('style')).toContain(
|
||||||
|
`height: ${customHeight}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
138
app/react/components/CodeEditor/DiffViewer.tsx
Normal file
138
app/react/components/CodeEditor/DiffViewer.tsx
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import CodeMirrorMerge from 'react-codemirror-merge';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
|
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
|
||||||
|
import styles from './CodeEditor.module.css';
|
||||||
|
import {
|
||||||
|
CodeEditorType,
|
||||||
|
useCodeEditorExtensions,
|
||||||
|
} from './useCodeEditorExtensions';
|
||||||
|
import { theme } from './CodeEditor';
|
||||||
|
|
||||||
|
const { Original } = CodeMirrorMerge;
|
||||||
|
const { Modified } = CodeMirrorMerge;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
originalCode: string;
|
||||||
|
newCode: string;
|
||||||
|
id: string;
|
||||||
|
type?: CodeEditorType;
|
||||||
|
placeholder?: string;
|
||||||
|
height?: string;
|
||||||
|
fileNames?: {
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
} & AutomationTestingProps;
|
||||||
|
|
||||||
|
const defaultCollapseUnchanged = {
|
||||||
|
margin: 10,
|
||||||
|
minSize: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiffViewer({
|
||||||
|
originalCode,
|
||||||
|
newCode,
|
||||||
|
id,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
type,
|
||||||
|
placeholder = 'No values found',
|
||||||
|
|
||||||
|
height = '500px',
|
||||||
|
fileNames,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const extensions = useCodeEditorExtensions(type);
|
||||||
|
const hasFileNames = !!fileNames?.original && !!fileNames?.modified;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasFileNames && (
|
||||||
|
<DiffFileNameHeaders
|
||||||
|
originalCopyText={originalCode}
|
||||||
|
modifiedCopyText={newCode}
|
||||||
|
originalFileName={fileNames.original}
|
||||||
|
modifiedFileName={fileNames.modified}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* additional div, so that the scroll gutter doesn't overlap with the rounded border, and always show scrollbar, so that the file name headers align */}
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
// tailwind doesn't like dynamic class names, so use a custom css variable for the height
|
||||||
|
// https://v3.tailwindcss.com/docs/content-configuration#dynamic-class-names
|
||||||
|
'--editor-min-height': height,
|
||||||
|
height,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className="h-full [scrollbar-gutter:stable] overflow-y-scroll"
|
||||||
|
>
|
||||||
|
<CodeMirrorMerge
|
||||||
|
theme={theme}
|
||||||
|
className={clsx(
|
||||||
|
styles.root,
|
||||||
|
// to give similar sizing to CodeEditor
|
||||||
|
'[&_.cm-content]:!min-h-[var(--editor-min-height)] [&_.cm-gutters]:!min-h-[var(--editor-min-height)] [&_.cm-editor>.cm-scroller]:!min-h-[var(--editor-min-height)]'
|
||||||
|
)}
|
||||||
|
id={id}
|
||||||
|
data-cy={dataCy}
|
||||||
|
collapseUnchanged={defaultCollapseUnchanged}
|
||||||
|
>
|
||||||
|
<Original
|
||||||
|
value={originalCode}
|
||||||
|
extensions={extensions}
|
||||||
|
readOnly
|
||||||
|
editable={false}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<Modified
|
||||||
|
value={newCode}
|
||||||
|
extensions={extensions}
|
||||||
|
readOnly
|
||||||
|
editable={false}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</CodeMirrorMerge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffFileNameHeaders({
|
||||||
|
originalCopyText,
|
||||||
|
modifiedCopyText,
|
||||||
|
originalFileName,
|
||||||
|
modifiedFileName,
|
||||||
|
}: {
|
||||||
|
originalCopyText: string;
|
||||||
|
modifiedCopyText: string;
|
||||||
|
originalFileName: string;
|
||||||
|
modifiedFileName: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FileNameHeaderRow>
|
||||||
|
<div className="w-1/2">
|
||||||
|
<FileNameHeader
|
||||||
|
fileName={originalFileName}
|
||||||
|
copyText={originalCopyText}
|
||||||
|
data-cy="original"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-px bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-gray-2" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<FileNameHeader
|
||||||
|
fileName={modifiedFileName}
|
||||||
|
copyText={modifiedCopyText}
|
||||||
|
data-cy="modified"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FileNameHeaderRow>
|
||||||
|
);
|
||||||
|
}
|
71
app/react/components/CodeEditor/FileNameHeader.tsx
Normal file
71
app/react/components/CodeEditor/FileNameHeader.tsx
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
|
import { CopyButton } from '@@/buttons/CopyButton';
|
||||||
|
|
||||||
|
type FileNameHeaderProps = {
|
||||||
|
fileName: string;
|
||||||
|
copyText: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
} & AutomationTestingProps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileNameHeaderRow: Outer container for file name headers (single or multiple columns).
|
||||||
|
* Use this to wrap one or more <FileNameHeader> components (and optional dividers).
|
||||||
|
*/
|
||||||
|
export function FileNameHeaderRow({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full text-sm text-muted border-0 border-b border-solid border-b-gray-5 th-dark:border-b-gray-7 th-highcontrast:border-b-gray-2 bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black [scrollbar-gutter:stable] overflow-auto',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileNameHeader: Renders a file name with a copy button, styled for use above a code editor or diff viewer.
|
||||||
|
* Should be used inside FileNameHeaderRow.
|
||||||
|
*/
|
||||||
|
export function FileNameHeader({
|
||||||
|
fileName,
|
||||||
|
copyText,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
}: FileNameHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'w-full overflow-auto flex justify-between items-center gap-x-2 px-4 py-1 text-sm text-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
<CopyButton
|
||||||
|
data-cy={dataCy}
|
||||||
|
copyText={copyText}
|
||||||
|
color="link"
|
||||||
|
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
|
||||||
|
indicatorPosition="left"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</CopyButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
app/react/components/CodeEditor/index.ts
Normal file
1
app/react/components/CodeEditor/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './CodeEditor';
|
91
app/react/components/CodeEditor/useCodeEditorExtensions.ts
Normal file
91
app/react/components/CodeEditor/useCodeEditorExtensions.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
StreamLanguage,
|
||||||
|
LanguageSupport,
|
||||||
|
syntaxHighlighting,
|
||||||
|
indentService,
|
||||||
|
} from '@codemirror/language';
|
||||||
|
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||||
|
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||||
|
import {
|
||||||
|
oneDarkHighlightStyle,
|
||||||
|
keymap,
|
||||||
|
Extension,
|
||||||
|
} from '@uiw/react-codemirror';
|
||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
import { lintKeymap, lintGutter } from '@codemirror/lint';
|
||||||
|
import { defaultKeymap } from '@codemirror/commands';
|
||||||
|
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||||
|
import { yamlCompletion, yamlSchema } from 'yaml-schema';
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
import { lineNumbers } from '@codemirror/view';
|
||||||
|
|
||||||
|
export type CodeEditorType = 'yaml' | 'shell' | 'dockerfile';
|
||||||
|
|
||||||
|
// Custom indentation service for YAML
|
||||||
|
const yamlIndentExtension = indentService.of((context, pos) => {
|
||||||
|
const prevLine = context.lineAt(pos, -1);
|
||||||
|
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
|
||||||
|
if (/:\s*$/.test(prevLine.text)) {
|
||||||
|
return prevIndent + 2;
|
||||||
|
}
|
||||||
|
return prevIndent;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dockerFileLanguage = new LanguageSupport(
|
||||||
|
StreamLanguage.define(dockerFile)
|
||||||
|
);
|
||||||
|
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
|
||||||
|
|
||||||
|
function yamlLanguage(schema?: JSONSchema7) {
|
||||||
|
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
|
||||||
|
|
||||||
|
return compact([
|
||||||
|
yaml,
|
||||||
|
linter,
|
||||||
|
stateExtensions,
|
||||||
|
yamlIndentExtension,
|
||||||
|
syntaxHighlighting(oneDarkHighlightStyle),
|
||||||
|
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
|
||||||
|
lineNumbers(),
|
||||||
|
lintGutter(),
|
||||||
|
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||||
|
// only show completions when a schema is provided
|
||||||
|
!!schema &&
|
||||||
|
autocompletion({
|
||||||
|
icons: false,
|
||||||
|
activateOnTypingDelay: 300,
|
||||||
|
selectOnOpen: true,
|
||||||
|
activateOnTyping: true,
|
||||||
|
override: [
|
||||||
|
(ctx) => {
|
||||||
|
const getCompletions = yamlCompletion();
|
||||||
|
const completions = getCompletions(ctx);
|
||||||
|
if (Array.isArray(completions)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
completions.validFor = /^\w*$/;
|
||||||
|
return completions;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCodeEditorExtensions(
|
||||||
|
type?: CodeEditorType,
|
||||||
|
schema?: JSONSchema7
|
||||||
|
): Extension[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
switch (type) {
|
||||||
|
case 'dockerfile':
|
||||||
|
return [dockerFileLanguage];
|
||||||
|
case 'shell':
|
||||||
|
return [shellLanguage];
|
||||||
|
case 'yaml':
|
||||||
|
return yamlLanguage(schema);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [type, schema]);
|
||||||
|
}
|
|
@ -13,21 +13,21 @@ export type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles: Record<Size, string> = {
|
const sizeStyles: Record<Size, string> = {
|
||||||
xs: 'text-xs',
|
xs: 'text-xs gap-1',
|
||||||
sm: 'text-sm',
|
sm: 'text-sm gap-2',
|
||||||
md: 'text-md',
|
md: 'text-md gap-2',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InlineLoader({ children, className, size = 'sm' }: Props) {
|
export function InlineLoader({ children, className, size = 'sm' }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'text-muted flex items-center gap-2',
|
'text-muted flex items-center',
|
||||||
className,
|
className,
|
||||||
sizeStyles[size]
|
sizeStyles[size]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon icon={Loader2} className="animate-spin-slow" />
|
<Icon icon={Loader2} className="animate-spin-slow flex-none" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,10 +1,19 @@
|
||||||
import { Option } from '@@/form-components/PortainerSelect';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// allow custom labels
|
||||||
|
export interface RadioGroupOption<TValue> {
|
||||||
|
value: TValue;
|
||||||
|
label: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props<T extends string | number> {
|
interface Props<T extends string | number> {
|
||||||
options: Array<Option<T>> | ReadonlyArray<Option<T>>;
|
options: Array<RadioGroupOption<T>> | ReadonlyArray<RadioGroupOption<T>>;
|
||||||
selectedOption: T;
|
selectedOption: T;
|
||||||
name: string;
|
name: string;
|
||||||
onOptionChange: (value: T) => void;
|
onOptionChange: (value: T) => void;
|
||||||
|
groupClassName?: string;
|
||||||
|
itemClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RadioGroup<T extends string | number = string>({
|
export function RadioGroup<T extends string | number = string>({
|
||||||
|
@ -12,13 +21,18 @@ export function RadioGroup<T extends string | number = string>({
|
||||||
selectedOption,
|
selectedOption,
|
||||||
name,
|
name,
|
||||||
onOptionChange,
|
onOptionChange,
|
||||||
|
groupClassName,
|
||||||
|
itemClassName,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
<div className={groupClassName ?? 'flex flex-wrap gap-x-2 gap-y-1'}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<label
|
<label
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="col-sm-3 col-lg-2 control-label !p-0 text-left font-normal"
|
className={
|
||||||
|
itemClassName ??
|
||||||
|
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
@ -28,6 +42,7 @@ export function RadioGroup<T extends string | number = string>({
|
||||||
onChange={() => onOptionChange(option.value)}
|
onChange={() => onOptionChange(option.value)}
|
||||||
style={{ margin: '0 4px 0 0' }}
|
style={{ margin: '0 4px 0 0' }}
|
||||||
data-cy={`radio-${option.value}`}
|
data-cy={`radio-${option.value}`}
|
||||||
|
disabled={option.disabled}
|
||||||
/>
|
/>
|
||||||
{option.label}
|
{option.label}
|
||||||
</label>
|
</label>
|
||||||
|
|
159
app/react/components/Sheet.tsx
Normal file
159
app/react/components/Sheet.tsx
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
import {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
forwardRef,
|
||||||
|
ElementRef,
|
||||||
|
PropsWithChildren,
|
||||||
|
} from 'react';
|
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { RefreshCw, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from './buttons';
|
||||||
|
|
||||||
|
// modified from shadcn sheet component
|
||||||
|
const Sheet = SheetPrimitive.Root;
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close;
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetDescription = SheetPrimitive.Description;
|
||||||
|
|
||||||
|
type SheetTitleProps = {
|
||||||
|
title: string;
|
||||||
|
onReload?(): Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// similar to the PageHeader component with simplified props and no breadcrumbs
|
||||||
|
function SheetHeader({
|
||||||
|
onReload,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<SheetTitleProps>) {
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12 pt-3 flex gap-2 justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SheetPrimitive.DialogTitle className="m-0 text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
|
||||||
|
{title}
|
||||||
|
</SheetPrimitive.DialogTitle>
|
||||||
|
{onReload ? (
|
||||||
|
<Button
|
||||||
|
color="none"
|
||||||
|
size="large"
|
||||||
|
onClick={onReload}
|
||||||
|
className="m-0 p-0 focus:text-inherit"
|
||||||
|
title="Refresh drawer content"
|
||||||
|
data-cy="sheet-refreshButton"
|
||||||
|
>
|
||||||
|
<RefreshCw className="icon" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SheetOverlay = forwardRef<
|
||||||
|
ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={clsx(
|
||||||
|
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
|
bottom:
|
||||||
|
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
|
left: 'inset-y-0 left-0 h-full w-[70vw] lg:w-[50vw] border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left max-w-2xl',
|
||||||
|
right:
|
||||||
|
'inset-y-0 right-0 h-full w-[70vw] lg:w-[50vw] border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right max-w-2xl',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: 'right',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SheetContent = forwardRef<
|
||||||
|
ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
side = 'right',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(sheetVariants({ side }), className)}
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title ? <SheetHeader title={title} /> : null}
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
asChild
|
||||||
|
className="absolute close-button right-9 top-8 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={X}
|
||||||
|
color="none"
|
||||||
|
className="btn-only-icon"
|
||||||
|
size="medium"
|
||||||
|
data-cy="sheet-closeButton"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
};
|
|
@ -74,6 +74,7 @@ export function WebEditorForm({
|
||||||
children,
|
children,
|
||||||
error,
|
error,
|
||||||
schema,
|
schema,
|
||||||
|
textTip,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
|
@ -99,6 +100,7 @@ export function WebEditorForm({
|
||||||
id={id}
|
id={id}
|
||||||
type="yaml"
|
type="yaml"
|
||||||
schema={schema as JSONSchema7}
|
schema={schema as JSONSchema7}
|
||||||
|
textTip={textTip}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
11
app/react/components/Widget/WidgetIcon.tsx
Normal file
11
app/react/components/Widget/WidgetIcon.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
export function WidgetIcon({ icon }: { icon: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="text-lg inline-flex items-center rounded-full bg-blue-3 text-blue-8 th-dark:bg-gray-9 th-dark:text-blue-3 p-2">
|
||||||
|
<Icon icon={icon} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@/react/components/Icon';
|
import { WidgetIcon } from './WidgetIcon';
|
||||||
|
|
||||||
import { useWidgetContext } from './Widget';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
|
@ -17,16 +15,12 @@ export function WidgetTitle({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
useWidgetContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="widget-header">
|
<div className="widget-header">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<span className={clsx('pull-left vertical-center', className)}>
|
<span className={clsx('pull-left vertical-center', className)}>
|
||||||
<div className="widget-icon">
|
<WidgetIcon icon={icon} />
|
||||||
<Icon icon={icon} className="space-right" />
|
<h2 className="text-base m-0 ml-1">{title}</h2>
|
||||||
</div>
|
|
||||||
<h2 className="text-base m-0">{title}</h2>
|
|
||||||
</span>
|
</span>
|
||||||
<span className={clsx('pull-right', className)}>{children}</span>
|
<span className={clsx('pull-right', className)}>{children}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,13 +26,13 @@ function Template({
|
||||||
|
|
||||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||||
Primary.args = {
|
Primary.args = {
|
||||||
children: 'Copy to clipboard',
|
children: 'Copy',
|
||||||
copyText: 'this will be copied to clipboard',
|
copyText: 'this will be copied to clipboard',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
|
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||||
NoCopyText.args = {
|
NoCopyText.args = {
|
||||||
children: 'Copy to clipboard without copied text',
|
children: 'Copy without copied text',
|
||||||
copyText: 'clipboard override',
|
copyText: 'clipboard override',
|
||||||
displayText: '',
|
displayText: '',
|
||||||
};
|
};
|
||||||
|
|
|
@ -58,7 +58,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||||
getRowId?(row: D): string;
|
getRowId?(row: D): string;
|
||||||
isRowSelectable?(row: Row<D>): boolean;
|
isRowSelectable?(row: Row<D>): boolean;
|
||||||
emptyContentLabel?: string;
|
emptyContentLabel?: string;
|
||||||
title?: string;
|
title?: React.ReactNode;
|
||||||
titleIcon?: IconProps['icon'];
|
titleIcon?: IconProps['icon'];
|
||||||
titleId?: string;
|
titleId?: string;
|
||||||
initialTableState?: Partial<TableState>;
|
initialTableState?: Partial<TableState>;
|
||||||
|
@ -71,6 +71,8 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||||
noWidget?: boolean;
|
noWidget?: boolean;
|
||||||
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
||||||
includeSearch?: boolean;
|
includeSearch?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Datatable<D extends DefaultType>({
|
export function Datatable<D extends DefaultType>({
|
||||||
|
@ -100,6 +102,8 @@ export function Datatable<D extends DefaultType>({
|
||||||
isServerSidePagination = false,
|
isServerSidePagination = false,
|
||||||
extendTableOptions = (value) => value,
|
extendTableOptions = (value) => value,
|
||||||
includeSearch,
|
includeSearch,
|
||||||
|
ariaLabel,
|
||||||
|
id,
|
||||||
}: Props<D> & PaginationProps) {
|
}: Props<D> & PaginationProps) {
|
||||||
const pageCount = useMemo(
|
const pageCount = useMemo(
|
||||||
() => Math.ceil(totalCount / settings.pageSize),
|
() => Math.ceil(totalCount / settings.pageSize),
|
||||||
|
@ -181,9 +185,14 @@ export function Datatable<D extends DefaultType>({
|
||||||
() => _.difference(selectedItems, filteredItems),
|
() => _.difference(selectedItems, filteredItems),
|
||||||
[selectedItems, filteredItems]
|
[selectedItems, filteredItems]
|
||||||
);
|
);
|
||||||
|
const { titleAriaLabel, contentAriaLabel } = getAriaLabels(
|
||||||
|
ariaLabel,
|
||||||
|
title,
|
||||||
|
titleId
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Container noWidget={noWidget} aria-label={title}>
|
<Table.Container noWidget={noWidget} aria-label={titleAriaLabel} id={id}>
|
||||||
<DatatableHeader
|
<DatatableHeader
|
||||||
onSearchChange={handleSearchBarChange}
|
onSearchChange={handleSearchBarChange}
|
||||||
searchValue={settings.search}
|
searchValue={settings.search}
|
||||||
|
@ -204,7 +213,7 @@ export function Datatable<D extends DefaultType>({
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onSortChange={handleSortChange}
|
onSortChange={handleSortChange}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
aria-label={`${title} table`}
|
aria-label={contentAriaLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DatatableFooter
|
<DatatableFooter
|
||||||
|
@ -239,6 +248,23 @@ export function Datatable<D extends DefaultType>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAriaLabels(
|
||||||
|
titleAriaLabel?: string,
|
||||||
|
title?: ReactNode,
|
||||||
|
titleId?: string
|
||||||
|
) {
|
||||||
|
if (titleAriaLabel) {
|
||||||
|
return { titleAriaLabel, contentAriaLabel: `${titleAriaLabel} table` };
|
||||||
|
}
|
||||||
|
if (typeof title === 'string') {
|
||||||
|
return { titleAriaLabel: title, contentAriaLabel: `${title} table` };
|
||||||
|
}
|
||||||
|
if (titleId) {
|
||||||
|
return { titleAriaLabel: titleId, contentAriaLabel: `${titleId} table` };
|
||||||
|
}
|
||||||
|
return { titleAriaLabel: 'table', contentAriaLabel: 'table' };
|
||||||
|
}
|
||||||
|
|
||||||
function defaultRenderRow<D extends DefaultType>(
|
function defaultRenderRow<D extends DefaultType>(
|
||||||
row: Row<D>,
|
row: Row<D>,
|
||||||
highlightedItemId?: string
|
highlightedItemId?: string
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { SearchBar } from './SearchBar';
|
||||||
import { Table } from './Table';
|
import { Table } from './Table';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: string;
|
title?: React.ReactNode;
|
||||||
titleIcon?: IconProps['icon'];
|
titleIcon?: IconProps['icon'];
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onSearchChange(value: string): void;
|
onSearchChange(value: string): void;
|
||||||
|
@ -52,7 +52,7 @@ export function DatatableHeader({
|
||||||
return (
|
return (
|
||||||
<Table.Title
|
<Table.Title
|
||||||
id={titleId}
|
id={titleId}
|
||||||
label={title ?? ''}
|
label={title}
|
||||||
icon={titleIcon}
|
icon={titleIcon}
|
||||||
description={description}
|
description={description}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
|
|
|
@ -6,12 +6,14 @@ interface Props {
|
||||||
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
|
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
|
||||||
noWidget?: boolean;
|
noWidget?: boolean;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableContainer({
|
export function TableContainer({
|
||||||
children,
|
children,
|
||||||
noWidget = false,
|
noWidget = false,
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
|
id,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
if (noWidget) {
|
if (noWidget) {
|
||||||
return (
|
return (
|
||||||
|
@ -25,7 +27,7 @@ export function TableContainer({
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<div className="datatable">
|
<div className="datatable">
|
||||||
<Widget aria-label={ariaLabel}>
|
<Widget aria-label={ariaLabel} id={id}>
|
||||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon?: ReactNode | ComponentType<unknown>;
|
icon?: ReactNode | ComponentType<unknown>;
|
||||||
label: string;
|
label: React.ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
|
@ -43,7 +43,7 @@ export function AdvancedMode({
|
||||||
id="environment-variables-editor"
|
id="environment-variables-editor"
|
||||||
value={editorValue}
|
value={editorValue}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
placeholder="e.g. key=value"
|
textTip="e.g. key=value"
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -10,14 +10,8 @@
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background-color: var(--bg-modal-content-color);
|
background-color: var(--bg-modal-content-color);
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
outline: 0;
|
|
||||||
|
|
||||||
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
|
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ export function Modal({
|
||||||
isOpen
|
isOpen
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles.overlay,
|
styles.overlay,
|
||||||
'z-50 flex items-center justify-center'
|
'flex items-center justify-center z-50'
|
||||||
)}
|
)}
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
@ -56,7 +56,13 @@ export function Modal({
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={clsx(styles.modalContent, 'relative', className)}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.modalContent,
|
||||||
|
'relative overflow-y-auto p-5 rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
{onDismiss && <CloseButton onClose={onDismiss} />}
|
{onDismiss && <CloseButton onClose={onDismiss} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -86,7 +86,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||||
id="edge-job-editor"
|
id="edge-job-editor"
|
||||||
onChange={(value) => setFieldValue('fileContent', value)}
|
onChange={(value) => setFieldValue('fileContent', value)}
|
||||||
value={values.fileContent}
|
value={values.fileContent}
|
||||||
placeholder="Define or paste the content of your script file here"
|
textTip="Define or paste the content of your script file here"
|
||||||
type="shell"
|
type="shell"
|
||||||
error={errors.fileContent}
|
error={errors.fileContent}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -82,7 +82,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||||
id="edge-job-editor"
|
id="edge-job-editor"
|
||||||
onChange={(value) => setFieldValue('fileContent', value)}
|
onChange={(value) => setFieldValue('fileContent', value)}
|
||||||
value={values.fileContent}
|
value={values.fileContent}
|
||||||
placeholder="Define or paste the content of your script file here"
|
textTip="Define or paste the content of your script file here"
|
||||||
type="shell"
|
type="shell"
|
||||||
error={errors.fileContent}
|
error={errors.fileContent}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -28,7 +28,7 @@ export function DockerContentField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
type="yaml"
|
type="yaml"
|
||||||
placeholder="Define or paste the content of your docker compose file here"
|
textTip="Define or paste the content of your docker compose file here"
|
||||||
error={error}
|
error={error}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
schema={dockerComposeSchemaQuery.data}
|
schema={dockerComposeSchemaQuery.data}
|
||||||
|
|
|
@ -74,7 +74,7 @@ export function KubeManifestForm({
|
||||||
value={values.fileContent}
|
value={values.fileContent}
|
||||||
onChange={(value) => handleChange({ fileContent: value })}
|
onChange={(value) => handleChange({ fileContent: value })}
|
||||||
type="yaml"
|
type="yaml"
|
||||||
placeholder="Define or paste the content of your manifest file here"
|
textTip="Define or paste the content of your manifest file here"
|
||||||
error={errors?.fileContent}
|
error={errors?.fileContent}
|
||||||
data-cy="stack-creation-editor"
|
data-cy="stack-creation-editor"
|
||||||
>
|
>
|
||||||
|
|
|
@ -66,7 +66,7 @@ export function ComposeForm({
|
||||||
type="yaml"
|
type="yaml"
|
||||||
schema={dockerComposeSchema}
|
schema={dockerComposeSchema}
|
||||||
id="compose-editor"
|
id="compose-editor"
|
||||||
placeholder="Define or paste the content of your docker compose file here"
|
textTip="Define or paste the content of your docker compose file here"
|
||||||
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
|
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
|
||||||
error={errors.content}
|
error={errors.content}
|
||||||
readonly={hasKubeEndpoint}
|
readonly={hasKubeEndpoint}
|
||||||
|
|
|
@ -37,7 +37,7 @@ export function KubernetesForm({
|
||||||
value={values.content}
|
value={values.content}
|
||||||
type="yaml"
|
type="yaml"
|
||||||
id="kube-manifest-editor"
|
id="kube-manifest-editor"
|
||||||
placeholder="Define or paste the content of your manifest here"
|
textTip="Define or paste the content of your manifest here"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
handleContentChange(DeploymentType.Kubernetes, value)
|
handleContentChange(DeploymentType.Kubernetes, value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,19 +34,23 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
//
|
//
|
||||||
// return (<Input value={debouncedValue} onChange={(e) => handleChange(e.target.value)} />)
|
// return (<Input value={debouncedValue} onChange={(e) => handleChange(e.target.value)} />)
|
||||||
// }
|
// }
|
||||||
export function useDebounce(value: string, onChange: (value: string) => void) {
|
export function useDebounce<T = string>(
|
||||||
|
value: T,
|
||||||
|
onChange: (value: T) => void,
|
||||||
|
delay = 300
|
||||||
|
) {
|
||||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
// Do not change. See notes above
|
// Do not change. See notes above
|
||||||
const onChangeDebouncer = useRef(
|
const onChangeDebouncer = useRef(
|
||||||
debounce(
|
debounce(
|
||||||
(value: string, onChangeFunc: (v: string) => void) => onChangeFunc(value),
|
(value: T, onChangeFunc: (v: T) => void) => onChangeFunc(value),
|
||||||
300
|
delay
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(value: string) => {
|
(value: T) => {
|
||||||
setDebouncedValue(value);
|
setDebouncedValue(value);
|
||||||
onChangeDebouncer.current(value, onChange);
|
onChangeDebouncer.current(value, onChange);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
|
||||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
@ -74,36 +75,32 @@ export function useApplicationEvents(
|
||||||
application
|
application
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// related events are events that have the application id, or the id of a service or pod from the application
|
||||||
|
const relatedUids = useMemo(() => {
|
||||||
|
const serviceIds = compact(
|
||||||
|
servicesQuery.data?.map((service) => service?.metadata?.uid)
|
||||||
|
);
|
||||||
|
const podIds = compact(podsQuery.data?.map((pod) => pod?.metadata?.uid));
|
||||||
|
return [application?.metadata?.uid, ...serviceIds, ...podIds];
|
||||||
|
}, [application?.metadata?.uid, podsQuery.data, servicesQuery.data]);
|
||||||
|
|
||||||
|
const relatedUidsSet = useMemo(() => new Set(relatedUids), [relatedUids]);
|
||||||
const { data: events, ...eventsQuery } = useEvents(environmentId, {
|
const { data: events, ...eventsQuery } = useEvents(environmentId, {
|
||||||
namespace,
|
namespace,
|
||||||
queryOptions: {
|
queryOptions: {
|
||||||
autoRefreshRate: options?.autoRefreshRate
|
autoRefreshRate: options?.autoRefreshRate
|
||||||
? options.autoRefreshRate * 1000
|
? options.autoRefreshRate * 1000
|
||||||
: undefined,
|
: undefined,
|
||||||
|
select: (data) =>
|
||||||
|
data.filter((event) => relatedUidsSet.has(event.involvedObject.uid)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// related events are events that have the application id, or the id of a service or pod from the application
|
|
||||||
const relatedEvents = useMemo(() => {
|
|
||||||
const serviceIds = servicesQuery.data?.map(
|
|
||||||
(service) => service?.metadata?.uid
|
|
||||||
);
|
|
||||||
const podIds = podsQuery.data?.map((pod) => pod?.metadata?.uid);
|
|
||||||
return (
|
|
||||||
events?.filter(
|
|
||||||
(event) =>
|
|
||||||
event.involvedObject.uid === application?.metadata?.uid ||
|
|
||||||
serviceIds?.includes(event.involvedObject.uid) ||
|
|
||||||
podIds?.includes(event.involvedObject.uid)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [application?.metadata?.uid, events, podsQuery.data, servicesQuery.data]);
|
|
||||||
|
|
||||||
const isInitialLoading =
|
const isInitialLoading =
|
||||||
applicationQuery.isInitialLoading ||
|
applicationQuery.isInitialLoading ||
|
||||||
servicesQuery.isInitialLoading ||
|
servicesQuery.isInitialLoading ||
|
||||||
podsQuery.isInitialLoading ||
|
podsQuery.isInitialLoading ||
|
||||||
eventsQuery.isInitialLoading;
|
eventsQuery.isInitialLoading;
|
||||||
|
|
||||||
return { relatedEvents, isInitialLoading };
|
return { relatedEvents: events || [], isInitialLoading };
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,6 +96,7 @@ export function ApplicationsDatatable({
|
||||||
item={row.original}
|
item={row.original}
|
||||||
hideStacks={hideStacks}
|
hideStacks={hideStacks}
|
||||||
areSecretsRestricted={!!restrictSecretsQuery.data}
|
areSecretsRestricted={!!restrictSecretsQuery.data}
|
||||||
|
selectDisabled={!hasWriteAuth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderTableActions={(selectedItems) =>
|
renderTableActions={(selectedItems) =>
|
||||||
|
|
|
@ -11,19 +11,22 @@ export function SubRow({
|
||||||
item,
|
item,
|
||||||
hideStacks,
|
hideStacks,
|
||||||
areSecretsRestricted,
|
areSecretsRestricted,
|
||||||
|
selectDisabled,
|
||||||
}: {
|
}: {
|
||||||
item: ApplicationRowData;
|
item: ApplicationRowData;
|
||||||
hideStacks: boolean;
|
hideStacks: boolean;
|
||||||
areSecretsRestricted: boolean;
|
areSecretsRestricted: boolean;
|
||||||
|
selectDisabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
user: { Username: username },
|
user: { Username: username },
|
||||||
} = useCurrentUser();
|
} = useCurrentUser();
|
||||||
const colSpan = hideStacks ? 7 : 8;
|
const colSpan = hideStacks ? 7 : 8;
|
||||||
|
const alignColSpan = selectDisabled ? 1 : 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={clsx({ 'secondary-body': !item.KubernetesApplications })}>
|
<tr className={clsx({ 'secondary-body': !item.KubernetesApplications })}>
|
||||||
<td colSpan={2} />
|
<td colSpan={alignColSpan} />
|
||||||
<td colSpan={colSpan} className="datatable-padding-vertical">
|
<td colSpan={colSpan} className="datatable-padding-vertical">
|
||||||
{item.KubernetesApplications ? (
|
{item.KubernetesApplications ? (
|
||||||
<InnerTable
|
<InnerTable
|
||||||
|
|
|
@ -35,7 +35,7 @@ export function EditYamlFormSection({
|
||||||
titleContent={<TitleContent isComposeFormat={isComposeFormat} />}
|
titleContent={<TitleContent isComposeFormat={isComposeFormat} />}
|
||||||
onChange={(values) => onChange(values)}
|
onChange={(values) => onChange(values)}
|
||||||
id={formId}
|
id={formId}
|
||||||
placeholder="Define or paste the content of your manifest file here"
|
textTip="Define or paste the content of your manifest file here"
|
||||||
type="yaml"
|
type="yaml"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Event } from 'kubernetes-types/core/v1';
|
import { Event } from 'kubernetes-types/core/v1';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { IndexOptional } from '@/react/kubernetes/configs/types';
|
import { IndexOptional } from '@/react/kubernetes/configs/types';
|
||||||
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||||
|
@ -16,6 +17,8 @@ type Props = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
'data-cy': string;
|
'data-cy': string;
|
||||||
noWidget?: boolean;
|
noWidget?: boolean;
|
||||||
|
title?: ReactNode;
|
||||||
|
titleIcon?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EventsDatatable({
|
export function EventsDatatable({
|
||||||
|
@ -24,6 +27,8 @@ export function EventsDatatable({
|
||||||
isLoading,
|
isLoading,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
noWidget,
|
noWidget,
|
||||||
|
title = 'Events',
|
||||||
|
titleIcon = History,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Datatable<IndexOptional<Event>>
|
<Datatable<IndexOptional<Event>>
|
||||||
|
@ -31,8 +36,8 @@ export function EventsDatatable({
|
||||||
columns={columns}
|
columns={columns}
|
||||||
settingsManager={tableState}
|
settingsManager={tableState}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
title="Events"
|
title={title}
|
||||||
titleIcon={History}
|
titleIcon={titleIcon}
|
||||||
getRowId={(row) => row.metadata?.uid || ''}
|
getRowId={(row) => row.metadata?.uid || ''}
|
||||||
disableSelect
|
disableSelect
|
||||||
renderTableSettings={() => (
|
renderTableSettings={() => (
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
|
import { Row } from '@tanstack/react-table';
|
||||||
|
import { Event } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
import { Badge, BadgeType } from '@@/Badge';
|
import { Badge, BadgeType } from '@@/Badge';
|
||||||
|
import { filterHOC } from '@@/datatables/Filter';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
@ -7,6 +11,14 @@ export const eventType = columnHelper.accessor('type', {
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<Badge type={getBadgeColor(getValue())}>{getValue()}</Badge>
|
<Badge type={getBadgeColor(getValue())}>{getValue()}</Badge>
|
||||||
),
|
),
|
||||||
|
|
||||||
|
meta: {
|
||||||
|
filter: filterHOC('Filter by event type'),
|
||||||
|
},
|
||||||
|
enableColumnFilter: true,
|
||||||
|
filterFn: (row: Row<Event>, _: string, filterValue: string[]) =>
|
||||||
|
filterValue.length === 0 ||
|
||||||
|
(!!row.original.type && filterValue.includes(row.original.type)),
|
||||||
});
|
});
|
||||||
|
|
||||||
function getBadgeColor(status?: string): BadgeType {
|
function getBadgeColor(status?: string): BadgeType {
|
||||||
|
|
|
@ -2,5 +2,6 @@ import { date } from './date';
|
||||||
import { kind } from './kind';
|
import { kind } from './kind';
|
||||||
import { eventType } from './eventType';
|
import { eventType } from './eventType';
|
||||||
import { message } from './message';
|
import { message } from './message';
|
||||||
|
import { name } from './name';
|
||||||
|
|
||||||
export const columns = [date, kind, eventType, message];
|
export const columns = [date, name, kind, eventType, message];
|
||||||
|
|
|
@ -1,8 +1,21 @@
|
||||||
|
import { Row } from '@tanstack/react-table';
|
||||||
|
import { Event } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { filterHOC } from '@@/datatables/Filter';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
export const kind = columnHelper.accessor(
|
export const kind = columnHelper.accessor(
|
||||||
(event) => event.involvedObject.kind,
|
(event) => event.involvedObject.kind,
|
||||||
{
|
{
|
||||||
header: 'Kind',
|
header: 'Kind',
|
||||||
|
meta: {
|
||||||
|
filter: filterHOC('Filter by kind'),
|
||||||
|
},
|
||||||
|
enableColumnFilter: true,
|
||||||
|
filterFn: (row: Row<Event>, _: string, filterValue: string[]) =>
|
||||||
|
filterValue.length === 0 ||
|
||||||
|
(!!row.original.involvedObject.kind &&
|
||||||
|
filterValue.includes(row.original.involvedObject.kind)),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
|
export const name = columnHelper.accessor(
|
||||||
|
(event) => event.involvedObject.name ?? '-',
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const name = getValue();
|
||||||
|
return (
|
||||||
|
<span title={name} className="ellipsis max-w-sm">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
|
@ -29,7 +29,7 @@ export function YAMLInspector({
|
||||||
<WebEditorForm
|
<WebEditorForm
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
value={yaml}
|
value={yaml}
|
||||||
placeholder={
|
textTip={
|
||||||
hideMessage
|
hideMessage
|
||||||
? undefined
|
? undefined
|
||||||
: 'Define or paste the content of your manifest here'
|
: 'Define or paste the content of your manifest here'
|
||||||
|
|
|
@ -1,31 +1,53 @@
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
import { RollbackButton } from './RollbackButton';
|
import { RollbackButton } from './RollbackButton';
|
||||||
import { UninstallButton } from './UninstallButton';
|
import { UninstallButton } from './UninstallButton';
|
||||||
|
import { UpgradeButton } from './UpgradeButton';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
releaseName: string;
|
||||||
|
namespace: string;
|
||||||
|
latestRevision?: number;
|
||||||
|
earlistRevision?: number;
|
||||||
|
selectedRevision?: number;
|
||||||
|
release?: HelmRelease;
|
||||||
|
updateRelease: (release: HelmRelease) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export function ChartActions({
|
export function ChartActions({
|
||||||
environmentId,
|
environmentId,
|
||||||
releaseName,
|
releaseName,
|
||||||
namespace,
|
namespace,
|
||||||
currentRevision,
|
latestRevision,
|
||||||
}: {
|
earlistRevision,
|
||||||
environmentId: EnvironmentId;
|
selectedRevision,
|
||||||
releaseName: string;
|
release,
|
||||||
namespace?: string;
|
updateRelease,
|
||||||
currentRevision?: number;
|
}: Props) {
|
||||||
}) {
|
const showRollbackButton =
|
||||||
const hasPreviousRevision = currentRevision && currentRevision >= 2;
|
latestRevision && earlistRevision && latestRevision > earlistRevision;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex gap-x-2">
|
<div className="inline-flex gap-2 flex-wrap">
|
||||||
|
<UpgradeButton
|
||||||
|
environmentId={environmentId}
|
||||||
|
releaseName={releaseName}
|
||||||
|
namespace={namespace}
|
||||||
|
release={release}
|
||||||
|
updateRelease={updateRelease}
|
||||||
|
/>
|
||||||
<UninstallButton
|
<UninstallButton
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
releaseName={releaseName}
|
releaseName={releaseName}
|
||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
/>
|
/>
|
||||||
{hasPreviousRevision && (
|
{showRollbackButton && (
|
||||||
<RollbackButton
|
<RollbackButton
|
||||||
latestRevision={currentRevision}
|
latestRevision={latestRevision}
|
||||||
|
selectedRevision={selectedRevision}
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
releaseName={releaseName}
|
releaseName={releaseName}
|
||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { vi, type Mock } from 'vitest';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
|
||||||
import { confirm } from '@@/modals/confirm';
|
import { confirm } from '@@/modals/confirm';
|
||||||
|
|
||||||
|
@ -25,13 +26,18 @@ vi.mock('@/portainer/services/notifications', () => ({
|
||||||
function renderButton(props = {}) {
|
function renderButton(props = {}) {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
latestRevision: 3, // So we're rolling back to revision 2
|
latestRevision: 3, // So we're rolling back to revision 2
|
||||||
|
selectedRevision: 3, // This simulates the selectedRevision from URL params
|
||||||
environmentId: 1,
|
environmentId: 1,
|
||||||
releaseName: 'test-release',
|
releaseName: 'test-release',
|
||||||
namespace: 'default',
|
namespace: 'default',
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wrapped = withTestQueryProvider(RollbackButton);
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(RollbackButton, {
|
||||||
|
route: '/?revision=3',
|
||||||
|
})
|
||||||
|
);
|
||||||
return render(<Wrapped {...defaultProps} />);
|
return render(<Wrapped {...defaultProps} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { RotateCcw } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
@ -12,6 +13,7 @@ import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
latestRevision: number;
|
latestRevision: number;
|
||||||
|
selectedRevision?: number;
|
||||||
environmentId: EnvironmentId;
|
environmentId: EnvironmentId;
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
|
@ -19,13 +21,16 @@ type Props = {
|
||||||
|
|
||||||
export function RollbackButton({
|
export function RollbackButton({
|
||||||
latestRevision,
|
latestRevision,
|
||||||
|
selectedRevision,
|
||||||
environmentId,
|
environmentId,
|
||||||
releaseName,
|
releaseName,
|
||||||
namespace,
|
namespace,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// the selectedRevision can be a prop when selecting a revision is implemented
|
// when the latest revision is selected, rollback to the previous revision
|
||||||
const selectedRevision = latestRevision ? latestRevision - 1 : undefined;
|
// otherwise, rollback to the selected revision
|
||||||
|
const rollbackRevision =
|
||||||
|
selectedRevision === latestRevision ? latestRevision - 1 : selectedRevision;
|
||||||
|
const router = useRouter();
|
||||||
const rollbackMutation = useHelmRollbackMutation(environmentId);
|
const rollbackMutation = useHelmRollbackMutation(environmentId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -38,7 +43,7 @@ export function RollbackButton({
|
||||||
color="default"
|
color="default"
|
||||||
size="medium"
|
size="medium"
|
||||||
>
|
>
|
||||||
Rollback to #{selectedRevision}
|
Rollback to #{rollbackRevision}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -47,7 +52,7 @@ export function RollbackButton({
|
||||||
title: 'Are you sure?',
|
title: 'Are you sure?',
|
||||||
modalType: ModalType.Warn,
|
modalType: ModalType.Warn,
|
||||||
confirmButton: buildConfirmButton('Rollback'),
|
confirmButton: buildConfirmButton('Rollback'),
|
||||||
message: `Rolling back will restore the application to revision #${selectedRevision}, which will cause service interruption. Do you wish to continue?`,
|
message: `Rolling back will restore the application to revision #${rollbackRevision}, which could cause service interruption. Do you wish to continue?`,
|
||||||
});
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
|
@ -56,14 +61,20 @@ export function RollbackButton({
|
||||||
rollbackMutation.mutate(
|
rollbackMutation.mutate(
|
||||||
{
|
{
|
||||||
releaseName,
|
releaseName,
|
||||||
params: { namespace, revision: selectedRevision },
|
params: { namespace, revision: rollbackRevision },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notifySuccess(
|
notifySuccess(
|
||||||
'Success',
|
'Success',
|
||||||
`Application rolled back to revision #${selectedRevision} successfully.`
|
`Application rolled back to revision #${rollbackRevision} successfully.`
|
||||||
);
|
);
|
||||||
|
// set the revision url param to undefined to refresh the page at the latest revision
|
||||||
|
router.stateService.go('kubernetes.helm', {
|
||||||
|
namespace,
|
||||||
|
name: releaseName,
|
||||||
|
revision: undefined,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useHelmRepoVersions,
|
||||||
|
ChartVersion,
|
||||||
|
} from '../queries/useHelmRepositories';
|
||||||
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
|
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||||
|
import { UpgradeButton } from './UpgradeButton';
|
||||||
|
|
||||||
|
// Mock the upgrade modal function
|
||||||
|
vi.mock('./UpgradeHelmModal', () => ({
|
||||||
|
openUpgradeHelmModal: vi.fn(() => Promise.resolve(undefined)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the notifications service
|
||||||
|
vi.mock('@/portainer/services/notifications', () => ({
|
||||||
|
notifySuccess: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the useHelmRepoVersions and useHelmRepositories hooks
|
||||||
|
vi.mock('../queries/useHelmRepositories', () => ({
|
||||||
|
useHelmRepoVersions: vi.fn(() => ({
|
||||||
|
data: [
|
||||||
|
{ Version: '1.0.0', Repo: 'stable' },
|
||||||
|
{ Version: '1.1.0', Repo: 'stable' },
|
||||||
|
],
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
})),
|
||||||
|
useHelmRepositories: vi.fn(() => ({
|
||||||
|
data: ['repo1', 'repo2'],
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderButton(props = {}) {
|
||||||
|
const defaultProps = {
|
||||||
|
environmentId: 1,
|
||||||
|
releaseName: 'test-release',
|
||||||
|
namespace: 'default',
|
||||||
|
release: {
|
||||||
|
name: 'test-release',
|
||||||
|
chart: {
|
||||||
|
metadata: {
|
||||||
|
name: 'test-chart',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
userSuppliedValues: '{}',
|
||||||
|
},
|
||||||
|
manifest: '',
|
||||||
|
} as HelmRelease,
|
||||||
|
updateRelease: vi.fn(),
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton));
|
||||||
|
return render(<Wrapped {...defaultProps} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UpgradeButton', () => {
|
||||||
|
test('should display the upgrade button', () => {
|
||||||
|
renderButton();
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be disabled when no versions are available', () => {
|
||||||
|
const data: ChartVersion[] = [];
|
||||||
|
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||||
|
data,
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show loading state when checking for versions', () => {
|
||||||
|
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isInitialLoading: true,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Checking for new versions...')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show "No versions available" when no versions are found', () => {
|
||||||
|
const data: ChartVersion[] = [];
|
||||||
|
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||||
|
data,
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
|
||||||
|
expect(screen.getByText('No versions available')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open upgrade modal when clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockRelease = {
|
||||||
|
name: 'test-release',
|
||||||
|
chart: {
|
||||||
|
metadata: {
|
||||||
|
name: 'test-chart',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
userSuppliedValues: '{}',
|
||||||
|
},
|
||||||
|
manifest: '',
|
||||||
|
} as HelmRelease;
|
||||||
|
|
||||||
|
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ Version: '1.0.0', Repo: 'stable' },
|
||||||
|
{ Version: '1.1.0', Repo: 'stable' },
|
||||||
|
],
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderButton({ release: mockRelease });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openUpgradeHelmModal).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'test-release',
|
||||||
|
chart: 'test-chart',
|
||||||
|
namespace: 'default',
|
||||||
|
values: '{}',
|
||||||
|
version: '1.0.0',
|
||||||
|
}),
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ Version: '1.0.0', Repo: 'stable' },
|
||||||
|
{ Version: '1.1.0', Repo: 'stable' },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not execute the upgrade if modal is cancelled', async () => {
|
||||||
|
const mockUpdateRelease = vi.fn();
|
||||||
|
vi.mocked(openUpgradeHelmModal).mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderButton({ updateRelease: mockUpdateRelease });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openUpgradeHelmModal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateRelease).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { ArrowUp } from 'lucide-react';
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { semverCompare } from '@/react/common/semver-utils';
|
||||||
|
|
||||||
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../../types';
|
||||||
|
import {
|
||||||
|
useUpdateHelmReleaseMutation,
|
||||||
|
UpdateHelmReleasePayload,
|
||||||
|
} from '../queries/useUpdateHelmReleaseMutation';
|
||||||
|
import {
|
||||||
|
ChartVersion,
|
||||||
|
useHelmRepoVersions,
|
||||||
|
useHelmRepositories,
|
||||||
|
} from '../queries/useHelmRepositories';
|
||||||
|
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||||
|
|
||||||
|
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||||
|
|
||||||
|
export function UpgradeButton({
|
||||||
|
environmentId,
|
||||||
|
releaseName,
|
||||||
|
namespace,
|
||||||
|
release,
|
||||||
|
updateRelease,
|
||||||
|
}: {
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
releaseName: string;
|
||||||
|
namespace: string;
|
||||||
|
release?: HelmRelease;
|
||||||
|
updateRelease: (release: HelmRelease) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||||
|
|
||||||
|
const repositoriesQuery = useHelmRepositories();
|
||||||
|
const helmRepoVersionsQuery = useHelmRepoVersions(
|
||||||
|
release?.chart.metadata?.name || '',
|
||||||
|
60 * 60 * 1000, // 1 hour
|
||||||
|
repositoriesQuery.data
|
||||||
|
);
|
||||||
|
const versions = helmRepoVersionsQuery.data;
|
||||||
|
|
||||||
|
// Combined loading state
|
||||||
|
const isInitialLoading =
|
||||||
|
repositoriesQuery.isInitialLoading ||
|
||||||
|
helmRepoVersionsQuery.isInitialLoading;
|
||||||
|
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
|
||||||
|
|
||||||
|
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
|
||||||
|
select: (data) => data.chart.metadata?.version,
|
||||||
|
});
|
||||||
|
const latestVersionAvailable = versions[0]?.Version ?? '';
|
||||||
|
const isNewVersionAvailable =
|
||||||
|
latestVersion?.data &&
|
||||||
|
semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
|
||||||
|
|
||||||
|
const editableHelmRelease: UpdateHelmReleasePayload = {
|
||||||
|
name: releaseName,
|
||||||
|
namespace: namespace || '',
|
||||||
|
values: release?.values?.userSuppliedValues,
|
||||||
|
chart: release?.chart.metadata?.name || '',
|
||||||
|
version: release?.chart.metadata?.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<LoadingButton
|
||||||
|
color="secondary"
|
||||||
|
data-cy="k8sApp-upgradeHelmChartButton"
|
||||||
|
onClick={() => openUpgradeForm(versions, release)}
|
||||||
|
disabled={
|
||||||
|
versions.length === 0 ||
|
||||||
|
isInitialLoading ||
|
||||||
|
isError ||
|
||||||
|
release?.info?.status?.startsWith('pending')
|
||||||
|
}
|
||||||
|
loadingText="Upgrading..."
|
||||||
|
isLoading={updateHelmReleaseMutation.isLoading}
|
||||||
|
icon={ArrowUp}
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</LoadingButton>
|
||||||
|
{versions.length === 0 && isInitialLoading && (
|
||||||
|
<InlineLoader
|
||||||
|
size="xs"
|
||||||
|
className="absolute -bottom-5 left-0 right-0 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Checking for new versions...
|
||||||
|
</InlineLoader>
|
||||||
|
)}
|
||||||
|
{versions.length === 0 && !isInitialLoading && !isError && (
|
||||||
|
<span className="absolute flex items-center -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
|
||||||
|
No versions available
|
||||||
|
<Tooltip
|
||||||
|
message={
|
||||||
|
<div>
|
||||||
|
Portainer is unable to find any versions for this chart in the
|
||||||
|
repositories saved. Try adding a new repository which contains
|
||||||
|
the chart in the{' '}
|
||||||
|
<Link
|
||||||
|
to="portainer.account"
|
||||||
|
params={{ '#': 'helm-repositories' }}
|
||||||
|
data-cy="user-settings-link"
|
||||||
|
>
|
||||||
|
Helm repositories settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isNewVersionAvailable && (
|
||||||
|
<span className="absolute -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
|
||||||
|
New version available ({latestVersionAvailable})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function openUpgradeForm(
|
||||||
|
versions: ChartVersion[],
|
||||||
|
release?: HelmRelease
|
||||||
|
) {
|
||||||
|
const result = await openUpgradeHelmModal(editableHelmRelease, versions);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
handleUpgrade(result, release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpgrade(
|
||||||
|
payload: UpdateHelmReleasePayload,
|
||||||
|
release?: HelmRelease
|
||||||
|
) {
|
||||||
|
if (release?.info) {
|
||||||
|
const updatedRelease = {
|
||||||
|
...release,
|
||||||
|
info: {
|
||||||
|
...release.info,
|
||||||
|
status: 'pending-upgrade',
|
||||||
|
description: 'Preparing upgrade',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateRelease(updatedRelease);
|
||||||
|
}
|
||||||
|
updateHelmReleaseMutation.mutate(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess('Success', 'Helm chart upgraded successfully');
|
||||||
|
// set the revision url param to undefined to refresh the page at the latest revision
|
||||||
|
router.stateService.go('kubernetes.helm', {
|
||||||
|
namespace,
|
||||||
|
name: releaseName,
|
||||||
|
revision: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
|
|
||||||
|
import { Modal, OnSubmit, openModal } from '@@/modals';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { WidgetTitle } from '@@/Widget';
|
||||||
|
|
||||||
|
import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
|
||||||
|
import { ChartVersion } from '../queries/useHelmRepositories';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
|
||||||
|
values: UpdateHelmReleasePayload;
|
||||||
|
versions: ChartVersion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
|
||||||
|
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
|
||||||
|
const isCurrentVersion = version.Version === values.version;
|
||||||
|
const label = `${version.Repo}@${version.Version}${
|
||||||
|
isCurrentVersion ? ' (current)' : ''
|
||||||
|
}`;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value: version,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const defaultVersion =
|
||||||
|
versionOptions.find((v) => v.value.Version === values.version)?.value ||
|
||||||
|
versionOptions[0]?.value;
|
||||||
|
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
|
||||||
|
const [userValues, setUserValues] = useState<string>(values.values || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onDismiss={() => onSubmit()}
|
||||||
|
size="lg"
|
||||||
|
className="flex flex-col h-[80vh] px-0"
|
||||||
|
aria-label="upgrade-helm"
|
||||||
|
>
|
||||||
|
<Modal.Header
|
||||||
|
title={<WidgetTitle className="px-5" title="Upgrade" icon={ArrowUp} />}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto px-5">
|
||||||
|
<Modal.Body>
|
||||||
|
<FormControl label="Version" inputId="version-input" size="vertical">
|
||||||
|
<PortainerSelect<ChartVersion>
|
||||||
|
value={version}
|
||||||
|
options={versionOptions}
|
||||||
|
onChange={(version) => {
|
||||||
|
if (version) {
|
||||||
|
setVersion(version);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-cy="helm-version-input"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 border-solid border-0 border-t border-gray-5 th-dark:border-gray-7 th-highcontrast:border-white">
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button
|
||||||
|
onClick={() => onSubmit()}
|
||||||
|
color="secondary"
|
||||||
|
key="cancel-button"
|
||||||
|
size="medium"
|
||||||
|
data-cy="cancel-button-cy"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
onSubmit({
|
||||||
|
name: values.name,
|
||||||
|
values: userValues,
|
||||||
|
namespace: values.namespace,
|
||||||
|
chart: values.chart,
|
||||||
|
repo: version.Repo,
|
||||||
|
version: version.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
color="primary"
|
||||||
|
key="update-button"
|
||||||
|
size="medium"
|
||||||
|
data-cy="update-button-cy"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUpgradeHelmModal(
|
||||||
|
values: UpdateHelmReleasePayload,
|
||||||
|
versions: ChartVersion[]
|
||||||
|
) {
|
||||||
|
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
|
||||||
|
values,
|
||||||
|
versions,
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import { HttpResponse } from 'msw';
|
import { HttpResponse } from 'msw';
|
||||||
|
|
||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
@ -7,12 +7,14 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
|
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
|
||||||
|
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
||||||
|
|
||||||
import { HelmApplicationView } from './HelmApplicationView';
|
import { HelmApplicationView } from './HelmApplicationView';
|
||||||
|
|
||||||
// Mock the necessary hooks and dependencies
|
// Mock the necessary hooks and dependencies
|
||||||
const mockUseCurrentStateAndParams = vi.fn();
|
const mockUseCurrentStateAndParams = vi.fn();
|
||||||
const mockUseEnvironmentId = vi.fn();
|
const mockUseEnvironmentId = vi.fn();
|
||||||
|
mockLocalizeDate();
|
||||||
|
|
||||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||||
...(await importOriginal()),
|
...(await importOriginal()),
|
||||||
|
@ -32,32 +34,109 @@ const minimalHelmRelease = {
|
||||||
chart: {
|
chart: {
|
||||||
metadata: {
|
metadata: {
|
||||||
name: 'test-chart',
|
name: 'test-chart',
|
||||||
// appVersion: '1.0.0', // can be missing for a minimal release
|
|
||||||
version: '2.2.2',
|
version: '2.2.2',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
status: 'deployed',
|
status: 'deployed',
|
||||||
|
last_deployed: '2021-01-01T00:00:00Z',
|
||||||
// notes: 'This is a test note', // can be missing for a minimal release
|
// notes: 'This is a test note', // can be missing for a minimal release
|
||||||
},
|
},
|
||||||
manifest: 'This is a test manifest',
|
manifest: 'This is a test manifest',
|
||||||
};
|
};
|
||||||
|
|
||||||
const helmReleaseWithAdditionalDetails = {
|
// Create a more complete helm release object for testing
|
||||||
...minimalHelmRelease,
|
const completeHelmRelease = {
|
||||||
info: {
|
name: 'test-release',
|
||||||
...minimalHelmRelease.info,
|
version: '1',
|
||||||
notes: 'This is a test note',
|
namespace: 'default',
|
||||||
},
|
|
||||||
chart: {
|
chart: {
|
||||||
...minimalHelmRelease.chart,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
...minimalHelmRelease.chart.metadata,
|
name: 'test-chart',
|
||||||
appVersion: '1.0.0',
|
appVersion: '1.0.0',
|
||||||
|
version: '2.2.2',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
info: {
|
||||||
|
status: 'deployed',
|
||||||
|
notes: 'This is a test note',
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
kind: 'Deployment',
|
||||||
|
name: 'test-deployment',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'test-deployment-uid',
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'Running',
|
||||||
|
message: 'All replicas are ready',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
name: 'test-deployment',
|
||||||
|
namespace: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Service',
|
||||||
|
name: 'test-service',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'test-service-uid',
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'Available',
|
||||||
|
message: 'Service is available',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
name: 'test-service',
|
||||||
|
namespace: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: 'This is a test manifest',
|
||||||
|
values: {
|
||||||
|
// Add some values to ensure the Values tab is present
|
||||||
|
replicaCount: 1,
|
||||||
|
image: {
|
||||||
|
repository: 'nginx',
|
||||||
|
tag: 'latest',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
kind: 'Deployment',
|
||||||
|
name: 'test-deployment',
|
||||||
|
namespace: 'default',
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'Running',
|
||||||
|
message: 'All replicas are ready',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
name: 'test-deployment',
|
||||||
|
namespace: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const helmReleaseHistory = [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
updated: '2023-06-01T12:00:00Z',
|
||||||
|
status: 'deployed',
|
||||||
|
chart: 'test-chart-1.0.0',
|
||||||
|
app_version: '1.0.0',
|
||||||
|
description: 'Install complete',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function renderComponent() {
|
function renderComponent() {
|
||||||
const user = new UserViewModel({ Username: 'user' });
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
const Wrapped = withTestQueryProvider(
|
const Wrapped = withTestQueryProvider(
|
||||||
|
@ -66,92 +145,171 @@ function renderComponent() {
|
||||||
return render(<Wrapped />);
|
return render(<Wrapped />);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('HelmApplicationView', () => {
|
describe(
|
||||||
beforeEach(() => {
|
'HelmApplicationView',
|
||||||
// Set up default mock values
|
() => {
|
||||||
mockUseEnvironmentId.mockReturnValue(3);
|
beforeEach(() => {
|
||||||
mockUseCurrentStateAndParams.mockReturnValue({
|
// Set up default mock values
|
||||||
params: {
|
mockUseEnvironmentId.mockReturnValue(3);
|
||||||
name: 'test-release',
|
mockUseCurrentStateAndParams.mockReturnValue({
|
||||||
namespace: 'default',
|
params: {
|
||||||
},
|
name: 'test-release',
|
||||||
|
namespace: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('should display helm release details for minimal release when data is loaded', async () => {
|
it('should display helm release details for minimal release when data is loaded', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||||
HttpResponse.json(minimalHelmRelease)
|
HttpResponse.json(minimalHelmRelease)
|
||||||
)
|
),
|
||||||
);
|
http.get('/api/users/undefined/helm/repositories', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
GlobalRepository: 'https://charts.helm.sh/stable',
|
||||||
|
UserRepositories: [
|
||||||
|
{ Id: '1', URL: 'https://charts.helm.sh/stable' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/templates/helm', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
entries: {
|
||||||
|
'test-chart': [{ version: '1.0.0' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
|
||||||
|
HttpResponse.json(helmReleaseHistory)
|
||||||
|
),
|
||||||
|
http.get(
|
||||||
|
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||||
|
() =>
|
||||||
|
HttpResponse.json({
|
||||||
|
kind: 'EventList',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
metadata: { resourceVersion: '12345' },
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const { findByText, findAllByText } = renderComponent();
|
const { findByText, findAllByText } = renderComponent();
|
||||||
|
|
||||||
// Check for the page header
|
// Check for the page header
|
||||||
expect(await findByText('Helm details')).toBeInTheDocument();
|
expect(await findByText('Helm details')).toBeInTheDocument();
|
||||||
|
|
||||||
// Check for the badge content
|
// Check for the badge content
|
||||||
expect(await findByText(/Namespace/)).toBeInTheDocument();
|
expect(await findByText(/Namespace: default/)).toBeInTheDocument();
|
||||||
expect(await findByText(/Chart version:/)).toBeInTheDocument();
|
expect(
|
||||||
expect(await findByText(/Chart:/)).toBeInTheDocument();
|
await findByText(/Chart version: test-chart-2.2.2/)
|
||||||
expect(await findByText(/Revision/)).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(await findByText(/Chart: test-chart/)).toBeInTheDocument();
|
||||||
|
expect(await findByText(/Revision: #1/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await findByText(/Last deployed: Jan 1, 2021, 12:00 AM/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Check for the actual values
|
||||||
|
expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
|
||||||
|
expect(await findAllByText(/test-chart/)).toHaveLength(2); // title and badge (not checking revision list item)
|
||||||
|
|
||||||
// Check for the actual values
|
// There shouldn't be a notes tab when there are no notes
|
||||||
expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
|
expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
|
||||||
expect(await findAllByText(/test-chart/)).toHaveLength(2);
|
|
||||||
|
|
||||||
// There shouldn't be a notes tab when there are no notes
|
// There shouldn't be an app version badge when it's missing
|
||||||
expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
// There shouldn't be an app version badge when it's missing
|
// Ensure there are no console errors
|
||||||
expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
|
// eslint-disable-next-line no-console
|
||||||
|
expect(console.error).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Ensure there are no console errors
|
// Restore console.error
|
||||||
// eslint-disable-next-line no-console
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
expect(console.error).not.toHaveBeenCalled();
|
});
|
||||||
|
|
||||||
// Restore console.error
|
it('should display error message when API request fails', async () => {
|
||||||
vi.spyOn(console, 'error').mockRestore();
|
// Mock API failure
|
||||||
});
|
server.use(
|
||||||
|
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||||
|
HttpResponse.error()
|
||||||
|
),
|
||||||
|
// Add mock for events endpoint
|
||||||
|
http.get(
|
||||||
|
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||||
|
() =>
|
||||||
|
HttpResponse.json({
|
||||||
|
kind: 'EventList',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
metadata: { resourceVersion: '12345' },
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
it('should display error message when API request fails', async () => {
|
// Mock console.error to prevent test output pollution
|
||||||
// Mock API failure
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
server.use(
|
|
||||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
|
||||||
HttpResponse.error()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock console.error to prevent test output pollution
|
renderComponent();
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
renderComponent();
|
// Wait for the error message to appear
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
'Failed to load Helm application details',
|
||||||
|
{},
|
||||||
|
{ timeout: 6500 }
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Wait for the error message to appear
|
// Restore console.error
|
||||||
expect(
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
await screen.findByText('Failed to load Helm application details')
|
});
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Restore console.error
|
it('should display additional details when available in helm release', async () => {
|
||||||
vi.spyOn(console, 'error').mockRestore();
|
server.use(
|
||||||
});
|
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||||
|
HttpResponse.json(completeHelmRelease)
|
||||||
|
),
|
||||||
|
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
|
||||||
|
HttpResponse.json(helmReleaseHistory)
|
||||||
|
),
|
||||||
|
http.get(
|
||||||
|
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||||
|
() =>
|
||||||
|
HttpResponse.json({
|
||||||
|
kind: 'EventList',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
metadata: { resourceVersion: '12345' },
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
it('should display additional details when available in helm release', async () => {
|
const { findByText } = renderComponent();
|
||||||
server.use(
|
|
||||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
|
||||||
HttpResponse.json(helmReleaseWithAdditionalDetails)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const { findByText } = renderComponent();
|
expect(await findByText('Helm details')).toBeInTheDocument();
|
||||||
|
|
||||||
// Check for the notes tab when notes are available
|
// Check for the app version badge when it's available
|
||||||
expect(await findByText(/Notes/)).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/App version/, { exact: false })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
// Check for the app version badge when it's available
|
await waitFor(() => {
|
||||||
expect(await findByText(/App version/)).toBeInTheDocument();
|
// Look for specific tab text
|
||||||
expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument();
|
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||||
});
|
expect(screen.getByText('Values')).toBeInTheDocument();
|
||||||
});
|
expect(screen.getByText('Manifest')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Events')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await findByText(/App version: 1.0.0/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: 7000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||||
import { PageHeader } from '@/react/components/PageHeader';
|
import { PageHeader } from '@/react/components/PageHeader';
|
||||||
|
@ -15,14 +16,25 @@ import { HelmSummary } from './HelmSummary';
|
||||||
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
||||||
import { useHelmRelease } from './queries/useHelmRelease';
|
import { useHelmRelease } from './queries/useHelmRelease';
|
||||||
import { ChartActions } from './ChartActions/ChartActions';
|
import { ChartActions } from './ChartActions/ChartActions';
|
||||||
|
import { HelmRevisionList } from './HelmRevisionList';
|
||||||
|
import { HelmRevisionListSheet } from './HelmRevisionListSheet';
|
||||||
|
import { useHelmHistory } from './queries/useHelmHistory';
|
||||||
|
|
||||||
export function HelmApplicationView() {
|
export function HelmApplicationView() {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { params } = useCurrentStateAndParams();
|
const { params } = useCurrentStateAndParams();
|
||||||
const { name, namespace } = params;
|
const { name, namespace, revision } = params;
|
||||||
|
const helmHistoryQuery = useHelmHistory(environmentId, name, namespace);
|
||||||
|
const latestRevision = helmHistoryQuery.data?.[0]?.version;
|
||||||
|
const earlistRevision =
|
||||||
|
helmHistoryQuery.data?.[helmHistoryQuery.data.length - 1]?.version;
|
||||||
|
// when loading the page fresh, the revision is undefined, so use the latest revision
|
||||||
|
const selectedRevision = revision ? parseInt(revision, 10) : latestRevision;
|
||||||
|
|
||||||
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
|
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
|
||||||
showResources: true,
|
showResources: true,
|
||||||
|
revision: selectedRevision,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -38,26 +50,61 @@ export function HelmApplicationView() {
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<Widget>
|
<Widget className="overflow-hidden">
|
||||||
{name && (
|
<div className="flex">
|
||||||
<WidgetTitle icon={helm} title={name}>
|
<div className="flex-1 min-w-0">
|
||||||
<Authorized authorizations="K8sApplicationsW">
|
{name && (
|
||||||
<ChartActions
|
<WidgetTitle icon={helm} title={name}>
|
||||||
environmentId={environmentId}
|
<div className="flex gap-2 flex-wrap">
|
||||||
releaseName={name}
|
<div className="2xl:hidden">
|
||||||
namespace={namespace}
|
<HelmRevisionListSheet
|
||||||
currentRevision={helmReleaseQuery.data?.version}
|
currentRevision={helmReleaseQuery.data?.version}
|
||||||
|
history={helmHistoryQuery.data}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Authorized authorizations="K8sApplicationsW">
|
||||||
|
<ChartActions
|
||||||
|
environmentId={environmentId}
|
||||||
|
releaseName={String(name)}
|
||||||
|
namespace={String(namespace)}
|
||||||
|
latestRevision={latestRevision ?? 1}
|
||||||
|
earlistRevision={earlistRevision}
|
||||||
|
selectedRevision={selectedRevision}
|
||||||
|
release={helmReleaseQuery.data}
|
||||||
|
updateRelease={(updatedRelease: HelmRelease) => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[
|
||||||
|
environmentId,
|
||||||
|
'helm',
|
||||||
|
'releases',
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
updatedRelease
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Authorized>
|
||||||
|
</div>
|
||||||
|
</WidgetTitle>
|
||||||
|
)}
|
||||||
|
<WidgetBody className="!pt-2.5">
|
||||||
|
<HelmDetails
|
||||||
|
isLoading={helmReleaseQuery.isInitialLoading}
|
||||||
|
isError={helmReleaseQuery.isError}
|
||||||
|
release={helmReleaseQuery.data}
|
||||||
|
selectedRevision={selectedRevision}
|
||||||
/>
|
/>
|
||||||
</Authorized>
|
</WidgetBody>
|
||||||
</WidgetTitle>
|
</div>
|
||||||
)}
|
<div className="w-80 hidden 2xl:!block">
|
||||||
<WidgetBody>
|
<HelmRevisionList
|
||||||
<HelmDetails
|
currentRevision={helmReleaseQuery.data?.version}
|
||||||
isLoading={helmReleaseQuery.isInitialLoading}
|
history={helmHistoryQuery.data}
|
||||||
isError={helmReleaseQuery.isError}
|
/>
|
||||||
release={helmReleaseQuery.data}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</WidgetBody>
|
|
||||||
</Widget>
|
</Widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -68,10 +115,16 @@ export function HelmApplicationView() {
|
||||||
type HelmDetailsProps = {
|
type HelmDetailsProps = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
release: HelmRelease | undefined;
|
selectedRevision?: number;
|
||||||
|
release?: HelmRelease;
|
||||||
};
|
};
|
||||||
|
|
||||||
function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
|
function HelmDetails({
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
release,
|
||||||
|
selectedRevision,
|
||||||
|
}: HelmDetailsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
@ -82,16 +135,16 @@ function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!release || !selectedRevision) {
|
||||||
return <Alert color="error" title="No Helm application details found" />;
|
return <Alert color="error" title="No Helm application details found" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HelmSummary release={data} />
|
<HelmSummary release={release} />
|
||||||
<div className="my-6 h-[1px] w-full bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-white" />
|
<div className="my-6 h-[1px] w-full bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-white" />
|
||||||
<Card className="bg-inherit">
|
<Card className="bg-inherit">
|
||||||
<ReleaseTabs release={data} />
|
<ReleaseTabs release={release} selectedRevision={selectedRevision} />
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../types';
|
||||||
|
|
||||||
|
import { HelmRevisionItem } from './HelmRevisionItem';
|
||||||
|
|
||||||
|
const mockHelmRelease: HelmRelease = {
|
||||||
|
name: 'my-release',
|
||||||
|
version: 1,
|
||||||
|
info: {
|
||||||
|
status: 'deployed',
|
||||||
|
last_deployed: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
metadata: {
|
||||||
|
name: 'my-app',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manifest: 'apiVersion: v1\nkind: Service\nmetadata:\n name: my-service',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLocalizeDate();
|
||||||
|
|
||||||
|
vi.mock('@uirouter/react', () => ({
|
||||||
|
useCurrentStateAndParams: () => ({
|
||||||
|
params: {
|
||||||
|
namespace: 'default',
|
||||||
|
name: 'my-release',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseCurrentStateAndParams = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||||
|
...(await importOriginal()),
|
||||||
|
useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getTestComponent() {
|
||||||
|
return withTestRouter(HelmRevisionItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelmRevisionItem', () => {
|
||||||
|
it('should display correct revision details', () => {
|
||||||
|
const TestComponent = getTestComponent();
|
||||||
|
render(
|
||||||
|
<TestComponent
|
||||||
|
item={mockHelmRelease}
|
||||||
|
namespace="default"
|
||||||
|
name="my-release"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check status badge
|
||||||
|
expect(screen.getByText('Deployed')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check revision number
|
||||||
|
expect(screen.getByText('Revision #1')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check chart name and version
|
||||||
|
expect(screen.getByText('my-app-1.0.0')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check deployment date
|
||||||
|
expect(screen.getByText('Jan 1, 2024, 12:00 AM')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have selected class when currentRevision matches item version', () => {
|
||||||
|
const TestComponent = getTestComponent();
|
||||||
|
const { container } = render(
|
||||||
|
<TestComponent
|
||||||
|
item={mockHelmRelease}
|
||||||
|
currentRevision={1}
|
||||||
|
namespace="default"
|
||||||
|
name="my-release"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blocklistItem = container.querySelector('.blocklist-item');
|
||||||
|
expect(blocklistItem).toHaveClass('blocklist-item--selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have selected class when currentRevision does not match item version', () => {
|
||||||
|
const TestComponent = getTestComponent();
|
||||||
|
const { container } = render(
|
||||||
|
<TestComponent
|
||||||
|
item={mockHelmRelease}
|
||||||
|
currentRevision={2}
|
||||||
|
namespace="default"
|
||||||
|
name="my-release"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blocklistItem = container.querySelector('.blocklist-item');
|
||||||
|
expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have selected class when currentRevision is undefined', () => {
|
||||||
|
const TestComponent = getTestComponent();
|
||||||
|
const { container } = render(
|
||||||
|
<TestComponent
|
||||||
|
item={mockHelmRelease}
|
||||||
|
namespace="default"
|
||||||
|
name="my-release"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blocklistItem = container.querySelector('.blocklist-item');
|
||||||
|
expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { localizeDate } from '@/react/common/date-utils';
|
||||||
|
|
||||||
|
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../types';
|
||||||
|
import { getStatusColor, getStatusText } from '../helm-status-utils';
|
||||||
|
|
||||||
|
export function HelmRevisionItem({
|
||||||
|
item,
|
||||||
|
currentRevision,
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
item: HelmRelease;
|
||||||
|
currentRevision?: number;
|
||||||
|
namespace: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<BlocklistItem
|
||||||
|
data-cy="helm-history-item"
|
||||||
|
isSelected={item.version === currentRevision}
|
||||||
|
as={Link}
|
||||||
|
to="kubernetes.helm"
|
||||||
|
params={{ namespace, name, revision: item.version }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="flex flex-wrap gap-1 justify-between">
|
||||||
|
<Badge type={getStatusColor(item.info?.status)}>
|
||||||
|
{getStatusText(item.info?.status)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted">Revision #{item.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 justify-between">
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{item.chart.metadata?.name}-{item.chart.metadata?.version}
|
||||||
|
</span>
|
||||||
|
{item.info?.last_deployed && (
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{localizeDate(new Date(item.info.last_deployed))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BlocklistItem>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
import { History } from 'lucide-react';
|
||||||
|
|
||||||
|
import { WidgetIcon } from '@@/Widget/WidgetIcon';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../types';
|
||||||
|
|
||||||
|
import { HelmRevisionItem } from './HelmRevisionItem';
|
||||||
|
|
||||||
|
export function HelmRevisionList({
|
||||||
|
currentRevision,
|
||||||
|
history,
|
||||||
|
}: {
|
||||||
|
currentRevision?: number;
|
||||||
|
history: HelmRelease[] | undefined;
|
||||||
|
}) {
|
||||||
|
const { params } = useCurrentStateAndParams();
|
||||||
|
const { name, namespace } = params;
|
||||||
|
|
||||||
|
if (!history) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-0 min-h-full overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
|
<div className="p-5 pb-2.5">
|
||||||
|
<span className="vertical-center mb-5">
|
||||||
|
<WidgetIcon icon={History} />
|
||||||
|
<h2 className="text-base m-0 ml-1">Revisions</h2>
|
||||||
|
</span>
|
||||||
|
{history?.map((historyItem) => (
|
||||||
|
<HelmRevisionItem
|
||||||
|
key={historyItem.version}
|
||||||
|
item={historyItem}
|
||||||
|
namespace={namespace}
|
||||||
|
name={name}
|
||||||
|
currentRevision={currentRevision}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@@/Sheet';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../types';
|
||||||
|
|
||||||
|
import { HelmRevisionList } from './HelmRevisionList';
|
||||||
|
|
||||||
|
export function HelmRevisionListSheet({
|
||||||
|
currentRevision,
|
||||||
|
history,
|
||||||
|
}: {
|
||||||
|
currentRevision: number | undefined;
|
||||||
|
history: HelmRelease[] | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger className="btn btn-link">
|
||||||
|
<Icon icon={Eye} />
|
||||||
|
View revisions
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="!w-80 !p-0 !pt-1 overflow-auto">
|
||||||
|
<div className="sr-only">
|
||||||
|
<SheetHeader title="Revisions" />
|
||||||
|
<SheetDescription>
|
||||||
|
View the history of this Helm application.
|
||||||
|
</SheetDescription>
|
||||||
|
</div>
|
||||||
|
<HelmRevisionList currentRevision={currentRevision} history={history} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,24 +1,19 @@
|
||||||
import { Badge } from '@/react/components/Badge';
|
import { Badge } from '@/react/components/Badge';
|
||||||
|
import { localizeDate } from '@/react/common/date-utils';
|
||||||
|
|
||||||
import { Alert } from '@@/Alert';
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
import { HelmRelease } from '../types';
|
import { HelmRelease } from '../types';
|
||||||
|
import {
|
||||||
|
DeploymentStatus,
|
||||||
|
getStatusColor,
|
||||||
|
getStatusText,
|
||||||
|
} from '../helm-status-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
release: HelmRelease;
|
release: HelmRelease;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DeploymentStatus {
|
|
||||||
DEPLOYED = 'deployed',
|
|
||||||
FAILED = 'failed',
|
|
||||||
PENDING = 'pending-install',
|
|
||||||
PENDINGUPGRADE = 'pending-upgrade',
|
|
||||||
PENDINGROLLBACK = 'pending-rollback',
|
|
||||||
SUPERSEDED = 'superseded',
|
|
||||||
UNINSTALLED = 'uninstalled',
|
|
||||||
UNINSTALLING = 'uninstalling',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HelmSummary({ release }: Props) {
|
export function HelmSummary({ release }: Props) {
|
||||||
const isSuccess =
|
const isSuccess =
|
||||||
release.info?.status === DeploymentStatus.DEPLOYED ||
|
release.info?.status === DeploymentStatus.DEPLOYED ||
|
||||||
|
@ -29,9 +24,14 @@ export function HelmSummary({ release }: Props) {
|
||||||
<div className="flex flex-col gap-y-4">
|
<div className="flex flex-col gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Badge type={getStatusColor(release.info?.status)}>
|
<Badge type={getStatusColor(release.info?.status)}>
|
||||||
{getText(release.info?.status)}
|
{getStatusText(release.info?.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
{!!release.info?.description && !isSuccess && (
|
||||||
|
<Alert color={getAlertColor(release.info?.status)}>
|
||||||
|
{release.info?.description}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{!!release.namespace && <Badge>Namespace: {release.namespace}</Badge>}
|
{!!release.namespace && <Badge>Namespace: {release.namespace}</Badge>}
|
||||||
{!!release.version && <Badge>Revision: #{release.version}</Badge>}
|
{!!release.version && <Badge>Revision: #{release.version}</Badge>}
|
||||||
|
@ -47,12 +47,13 @@ export function HelmSummary({ release }: Props) {
|
||||||
{release.chart.metadata.version}
|
{release.chart.metadata.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{!!release.info?.last_deployed && (
|
||||||
|
<Badge>
|
||||||
|
Last deployed:{' '}
|
||||||
|
{localizeDate(new Date(release.info.last_deployed))}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!release.info?.description && !isSuccess && (
|
|
||||||
<Alert color={getAlertColor(release.info?.status)}>
|
|
||||||
{release.info?.description}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -74,38 +75,3 @@ function getAlertColor(status?: string) {
|
||||||
return 'info';
|
return 'info';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status?: string) {
|
|
||||||
switch (status?.toLowerCase()) {
|
|
||||||
case DeploymentStatus.DEPLOYED:
|
|
||||||
return 'success';
|
|
||||||
case DeploymentStatus.FAILED:
|
|
||||||
return 'danger';
|
|
||||||
case DeploymentStatus.PENDING:
|
|
||||||
case DeploymentStatus.PENDINGUPGRADE:
|
|
||||||
case DeploymentStatus.PENDINGROLLBACK:
|
|
||||||
case DeploymentStatus.UNINSTALLING:
|
|
||||||
return 'warn';
|
|
||||||
case DeploymentStatus.SUPERSEDED:
|
|
||||||
default:
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getText(status?: string) {
|
|
||||||
switch (status?.toLowerCase()) {
|
|
||||||
case DeploymentStatus.DEPLOYED:
|
|
||||||
return 'Deployed';
|
|
||||||
case DeploymentStatus.FAILED:
|
|
||||||
return 'Failed';
|
|
||||||
case DeploymentStatus.PENDING:
|
|
||||||
case DeploymentStatus.PENDINGUPGRADE:
|
|
||||||
case DeploymentStatus.PENDINGROLLBACK:
|
|
||||||
case DeploymentStatus.UNINSTALLING:
|
|
||||||
return 'Pending';
|
|
||||||
case DeploymentStatus.SUPERSEDED:
|
|
||||||
return 'Superseded';
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { DiffControl, DiffViewMode } from './DiffControl';
|
||||||
|
|
||||||
|
// Create a mock for useDebounce that directly passes the setter function
|
||||||
|
vi.mock('@/react/hooks/useDebounce', () => ({
|
||||||
|
useDebounce: (initialValue: number, setter: (value: number) => void) =>
|
||||||
|
// Return the initial value and a function that directly calls the setter
|
||||||
|
[initialValue, setter],
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderComponent({
|
||||||
|
selectedRevisionNumber = 5,
|
||||||
|
latestRevisionNumber = 10,
|
||||||
|
compareRevisionNumber = 4,
|
||||||
|
setCompareRevisionNumber = vi.fn(),
|
||||||
|
earliestRevisionNumber = 1,
|
||||||
|
diffViewMode = 'view' as DiffViewMode,
|
||||||
|
setDiffViewMode = vi.fn(),
|
||||||
|
isUserSupplied = false,
|
||||||
|
setIsUserSupplied = vi.fn(),
|
||||||
|
showUserSuppliedCheckbox = false,
|
||||||
|
} = {}) {
|
||||||
|
return render(
|
||||||
|
<DiffControl
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
latestRevisionNumber={latestRevisionNumber}
|
||||||
|
compareRevisionNumber={compareRevisionNumber}
|
||||||
|
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||||
|
earliestRevisionNumber={earliestRevisionNumber}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
setDiffViewMode={setDiffViewMode}
|
||||||
|
isUserSupplied={isUserSupplied}
|
||||||
|
setIsUserSupplied={setIsUserSupplied}
|
||||||
|
showUserSuppliedCheckbox={showUserSuppliedCheckbox}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DiffControl', () => {
|
||||||
|
it('should only render the user supplied checkbox when latestRevisionNumber is 1 and showUserSuppliedCheckbox is true', () => {
|
||||||
|
const { queryByLabelText } = renderComponent({
|
||||||
|
latestRevisionNumber: 1,
|
||||||
|
showUserSuppliedCheckbox: true,
|
||||||
|
setIsUserSupplied: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(queryByLabelText('View')).toBeNull();
|
||||||
|
expect(queryByLabelText('Diff with previous')).toBeNull();
|
||||||
|
expect(queryByLabelText('Diff with specific revision:')).toBeNull();
|
||||||
|
expect(queryByLabelText('User defined only')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render any controls when latestRevisionNumber is 1 and showUserSuppliedCheckbox is false', () => {
|
||||||
|
const { queryByLabelText } = renderComponent({
|
||||||
|
latestRevisionNumber: 1,
|
||||||
|
showUserSuppliedCheckbox: false,
|
||||||
|
});
|
||||||
|
expect(queryByLabelText('Diff with previous')).toBeNull();
|
||||||
|
expect(queryByLabelText('Diff with specific revision:')).toBeNull();
|
||||||
|
expect(queryByLabelText('View')).toBeNull();
|
||||||
|
expect(queryByLabelText('User defined only')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render view option', () => {
|
||||||
|
const { getByLabelText } = renderComponent();
|
||||||
|
expect(getByLabelText('View')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render "Diff with previous" option when earliestRevisionNumber < selectedRevisionNumber', () => {
|
||||||
|
const { getByLabelText } = renderComponent({
|
||||||
|
earliestRevisionNumber: 3,
|
||||||
|
selectedRevisionNumber: 5,
|
||||||
|
});
|
||||||
|
expect(getByLabelText('Diff with previous')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render "Diff with previous" option as disabled when earliestRevisionNumber >= selectedRevisionNumber', () => {
|
||||||
|
const { getByLabelText } = renderComponent({
|
||||||
|
earliestRevisionNumber: 5,
|
||||||
|
selectedRevisionNumber: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByLabelText('View')).toBeInTheDocument();
|
||||||
|
expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
|
||||||
|
// 'Diff with previous' should exist and be disabled
|
||||||
|
const diffWithPreviousOption = getByLabelText('Diff with previous');
|
||||||
|
expect(diffWithPreviousOption).toBeInTheDocument();
|
||||||
|
expect(diffWithPreviousOption).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render "Diff with specific revision" option', () => {
|
||||||
|
const { getByLabelText } = renderComponent();
|
||||||
|
expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render user supplied checkbox when showUserSuppliedCheckbox is true', () => {
|
||||||
|
const { getByLabelText } = renderComponent({
|
||||||
|
showUserSuppliedCheckbox: true,
|
||||||
|
});
|
||||||
|
expect(getByLabelText('User defined only')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render user supplied checkbox when showUserSuppliedCheckbox is false', () => {
|
||||||
|
const { queryByLabelText } = renderComponent({
|
||||||
|
showUserSuppliedCheckbox: false,
|
||||||
|
});
|
||||||
|
expect(queryByLabelText('User defined only')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setDiffViewMode when a radio option is selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const setDiffViewMode = vi.fn();
|
||||||
|
|
||||||
|
const { getByLabelText } = renderComponent({
|
||||||
|
setDiffViewMode,
|
||||||
|
diffViewMode: 'view',
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(getByLabelText('Diff with specific revision:'));
|
||||||
|
expect(setDiffViewMode).toHaveBeenCalledWith('specific');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setIsUserSupplied when checkbox is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const setIsUserSupplied = vi.fn();
|
||||||
|
|
||||||
|
const { getByLabelText } = renderComponent({
|
||||||
|
setIsUserSupplied,
|
||||||
|
isUserSupplied: false,
|
||||||
|
showUserSuppliedCheckbox: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(getByLabelText('User defined only'));
|
||||||
|
expect(setIsUserSupplied).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DiffWithSpecificRevision', () => {
|
||||||
|
it('should display input with compareRevisionNumber value when not NaN', () => {
|
||||||
|
const compareRevisionNumber = 3;
|
||||||
|
const { getByRole } = renderComponent({
|
||||||
|
diffViewMode: 'specific',
|
||||||
|
compareRevisionNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = getByRole('spinbutton');
|
||||||
|
expect(input).toHaveValue(compareRevisionNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle input values and constraints properly', () => {
|
||||||
|
const setCompareRevisionNumber = vi.fn();
|
||||||
|
const earliestRevisionNumber = 2;
|
||||||
|
const latestRevisionNumber = 10;
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({
|
||||||
|
diffViewMode: 'specific',
|
||||||
|
earliestRevisionNumber,
|
||||||
|
latestRevisionNumber,
|
||||||
|
setCompareRevisionNumber,
|
||||||
|
compareRevisionNumber: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that input has the right min/max attributes
|
||||||
|
const input = getByRole('spinbutton');
|
||||||
|
expect(input).toHaveAttribute('min', earliestRevisionNumber.toString());
|
||||||
|
expect(input).toHaveAttribute('max', latestRevisionNumber.toString());
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { valueAsNumber: 11 } });
|
||||||
|
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
|
||||||
|
latestRevisionNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { valueAsNumber: 1 } });
|
||||||
|
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
|
||||||
|
earliestRevisionNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { valueAsNumber: 5 } });
|
||||||
|
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN values in the input as empty string', () => {
|
||||||
|
const { getByRole } = renderComponent({
|
||||||
|
diffViewMode: 'specific',
|
||||||
|
compareRevisionNumber: NaN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = getByRole('spinbutton') as HTMLInputElement;
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||||
|
|
||||||
|
import { RadioGroup, RadioGroupOption } from '@@/RadioGroup/RadioGroup';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LatestRevisionNumber,
|
||||||
|
EarliestRevisionNumber,
|
||||||
|
CompareRevisionNumber,
|
||||||
|
SelectedRevisionNumber,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export type DiffViewMode = 'view' | 'previous' | 'specific';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedRevisionNumber: SelectedRevisionNumber;
|
||||||
|
latestRevisionNumber: LatestRevisionNumber;
|
||||||
|
compareRevisionNumber: CompareRevisionNumber;
|
||||||
|
setCompareRevisionNumber: (
|
||||||
|
compareRevisionNumber: CompareRevisionNumber
|
||||||
|
) => void;
|
||||||
|
earliestRevisionNumber: EarliestRevisionNumber;
|
||||||
|
diffViewMode: DiffViewMode;
|
||||||
|
setDiffViewMode: (diffViewMode: DiffViewMode) => void;
|
||||||
|
isUserSupplied?: boolean;
|
||||||
|
setIsUserSupplied?: (isUserSupplied: boolean) => void;
|
||||||
|
showUserSuppliedCheckbox?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiffControl({
|
||||||
|
selectedRevisionNumber,
|
||||||
|
latestRevisionNumber,
|
||||||
|
compareRevisionNumber,
|
||||||
|
setCompareRevisionNumber,
|
||||||
|
earliestRevisionNumber,
|
||||||
|
diffViewMode,
|
||||||
|
setDiffViewMode,
|
||||||
|
isUserSupplied,
|
||||||
|
setIsUserSupplied,
|
||||||
|
showUserSuppliedCheckbox,
|
||||||
|
}: Props) {
|
||||||
|
// If there is a different version to compare, show view option radio group
|
||||||
|
const showViewOptions = latestRevisionNumber > earliestRevisionNumber;
|
||||||
|
|
||||||
|
// to show the previous option, the earliest revision number available must be less than the selected revision number. (compare is still allowed, because we can still compare with a later revision)
|
||||||
|
const disabledPreviousOption =
|
||||||
|
earliestRevisionNumber >= selectedRevisionNumber;
|
||||||
|
|
||||||
|
const options: Array<RadioGroupOption<DiffViewMode>> = [
|
||||||
|
{ label: 'View', value: 'view' },
|
||||||
|
{
|
||||||
|
label: 'Diff with previous',
|
||||||
|
value: 'previous',
|
||||||
|
disabled: disabledPreviousOption,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<DiffWithSpecificRevision
|
||||||
|
latestRevisionNumber={latestRevisionNumber}
|
||||||
|
earliestRevisionNumber={earliestRevisionNumber}
|
||||||
|
compareRevisionNumber={compareRevisionNumber}
|
||||||
|
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
value: 'specific',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-x-16 gap-y-1 items-center">
|
||||||
|
{showViewOptions && (
|
||||||
|
<RadioGroup
|
||||||
|
options={options}
|
||||||
|
selectedOption={diffViewMode}
|
||||||
|
name="diffControl"
|
||||||
|
onOptionChange={setDiffViewMode}
|
||||||
|
groupClassName="inline-flex flex-wrap gap-x-16 gap-y-1"
|
||||||
|
itemClassName="control-label !p-0 text-left font-normal"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!showUserSuppliedCheckbox && !!setIsUserSupplied && (
|
||||||
|
<Checkbox
|
||||||
|
label="User defined only"
|
||||||
|
id="values-details-user-supplied"
|
||||||
|
checked={isUserSupplied}
|
||||||
|
onChange={() => setIsUserSupplied(!isUserSupplied)}
|
||||||
|
data-cy="values-details-user-supplied"
|
||||||
|
className="font-normal control-label"
|
||||||
|
bold={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffWithSpecificRevision({
|
||||||
|
latestRevisionNumber,
|
||||||
|
earliestRevisionNumber,
|
||||||
|
compareRevisionNumber,
|
||||||
|
setCompareRevisionNumber,
|
||||||
|
}: {
|
||||||
|
latestRevisionNumber: LatestRevisionNumber;
|
||||||
|
earliestRevisionNumber: EarliestRevisionNumber;
|
||||||
|
compareRevisionNumber: CompareRevisionNumber;
|
||||||
|
setCompareRevisionNumber: (
|
||||||
|
compareRevisionNumber: CompareRevisionNumber
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
// the revision number is debounced to avoid too many requests to the backend
|
||||||
|
const [
|
||||||
|
debouncedSetCompareRevisionNumber,
|
||||||
|
setDebouncedSetCompareRevisionNumber,
|
||||||
|
] = useDebounce(compareRevisionNumber, setCompareRevisionNumber, 500);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>Diff with specific revision:</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={earliestRevisionNumber}
|
||||||
|
max={latestRevisionNumber}
|
||||||
|
value={debouncedSetCompareRevisionNumber}
|
||||||
|
onChange={handleSpecificRevisionChange}
|
||||||
|
className="w-20 ml-2"
|
||||||
|
data-cy="revision-specific-input"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSpecificRevisionChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const inputNumber = e.target.valueAsNumber;
|
||||||
|
// handle out of range values
|
||||||
|
if (inputNumber > latestRevisionNumber) {
|
||||||
|
setCompareRevisionNumber(latestRevisionNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inputNumber < earliestRevisionNumber) {
|
||||||
|
setCompareRevisionNumber(earliestRevisionNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDebouncedSetCompareRevisionNumber(inputNumber);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
|
import { DiffViewer } from '@@/CodeEditor/DiffViewer';
|
||||||
|
import { Loading } from '@@/Widget';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
|
import { CompareRevisionNumberFetched, SelectedRevisionNumber } from './types';
|
||||||
|
|
||||||
|
interface Props extends AutomationTestingProps {
|
||||||
|
isCompareReleaseLoading: boolean;
|
||||||
|
isCompareReleaseError: boolean;
|
||||||
|
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||||
|
selectedRevisionNumber: SelectedRevisionNumber;
|
||||||
|
newText: string;
|
||||||
|
originalText: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffViewSection({
|
||||||
|
isCompareReleaseLoading,
|
||||||
|
isCompareReleaseError,
|
||||||
|
compareRevisionNumberFetched,
|
||||||
|
selectedRevisionNumber,
|
||||||
|
newText,
|
||||||
|
originalText,
|
||||||
|
id,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
}: Props) {
|
||||||
|
if (isCompareReleaseLoading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompareReleaseError) {
|
||||||
|
return <Alert color="error">Error loading compare values</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DiffViewer
|
||||||
|
newCode={newText}
|
||||||
|
originalCode={originalText}
|
||||||
|
id={id}
|
||||||
|
data-cy={dataCy}
|
||||||
|
placeholder="No values found"
|
||||||
|
fileNames={{
|
||||||
|
original: compareRevisionNumberFetched
|
||||||
|
? `Revision #${compareRevisionNumberFetched}`
|
||||||
|
: 'No revision selected',
|
||||||
|
modified: `Revision #${selectedRevisionNumber}`,
|
||||||
|
}}
|
||||||
|
className="mt-2"
|
||||||
|
type="yaml"
|
||||||
|
height="60vh"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { HttpResponse } from 'msw';
|
||||||
|
import { Event, EventList } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { server, http } from '@/setup-tests/server';
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
||||||
|
|
||||||
|
import { GenericResource } from '../../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HelmEventsDatatable,
|
||||||
|
filterRelatedEvents,
|
||||||
|
} from './HelmEventsDatatable';
|
||||||
|
|
||||||
|
const mockUseEnvironmentId = vi.fn();
|
||||||
|
mockLocalizeDate();
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
|
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const testResources: GenericResource[] = [
|
||||||
|
{
|
||||||
|
kind: 'Deployment',
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'Running',
|
||||||
|
message: 'All replicas are ready',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
name: 'test-deployment',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'test-deployment-uid',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Service',
|
||||||
|
status: {
|
||||||
|
healthSummary: {
|
||||||
|
status: 'Healthy',
|
||||||
|
reason: 'Available',
|
||||||
|
message: 'Service is available',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
name: 'test-service',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'test-service-uid',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockEventsResponse: EventList = {
|
||||||
|
kind: 'EventList',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
metadata: {
|
||||||
|
resourceVersion: '12345',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
name: 'test-deployment-123456',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'event-uid-1',
|
||||||
|
resourceVersion: '1000',
|
||||||
|
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
involvedObject: {
|
||||||
|
kind: 'Deployment',
|
||||||
|
namespace: 'default',
|
||||||
|
name: 'test-deployment',
|
||||||
|
uid: 'test-deployment-uid',
|
||||||
|
apiVersion: 'apps/v1',
|
||||||
|
resourceVersion: '2000',
|
||||||
|
},
|
||||||
|
reason: 'ScalingReplicaSet',
|
||||||
|
message: 'Scaled up replica set test-deployment-abc123 to 1',
|
||||||
|
source: {
|
||||||
|
component: 'deployment-controller',
|
||||||
|
},
|
||||||
|
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
count: 1,
|
||||||
|
type: 'Normal',
|
||||||
|
reportingComponent: 'deployment-controller',
|
||||||
|
reportingInstance: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
name: 'test-service-123456',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'event-uid-2',
|
||||||
|
resourceVersion: '1001',
|
||||||
|
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
involvedObject: {
|
||||||
|
kind: 'Service',
|
||||||
|
namespace: 'default',
|
||||||
|
name: 'test-service',
|
||||||
|
uid: 'test-service-uid',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
resourceVersion: '2001',
|
||||||
|
},
|
||||||
|
reason: 'CreatedLoadBalancer',
|
||||||
|
message: 'Created load balancer',
|
||||||
|
source: {
|
||||||
|
component: 'service-controller',
|
||||||
|
},
|
||||||
|
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
count: 1,
|
||||||
|
type: 'Normal',
|
||||||
|
reportingComponent: 'service-controller',
|
||||||
|
reportingInstance: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mixedEventsResponse: EventList = {
|
||||||
|
kind: 'EventList',
|
||||||
|
apiVersion: 'v1',
|
||||||
|
metadata: {
|
||||||
|
resourceVersion: '12345',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
name: 'test-deployment-123456',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'event-uid-1',
|
||||||
|
resourceVersion: '1000',
|
||||||
|
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
involvedObject: {
|
||||||
|
kind: 'Deployment',
|
||||||
|
namespace: 'default',
|
||||||
|
name: 'test-deployment',
|
||||||
|
uid: 'test-deployment-uid', // This matches a resource UID
|
||||||
|
apiVersion: 'apps/v1',
|
||||||
|
resourceVersion: '2000',
|
||||||
|
},
|
||||||
|
reason: 'ScalingReplicaSet',
|
||||||
|
message: 'Scaled up replica set test-deployment-abc123 to 1',
|
||||||
|
source: {
|
||||||
|
component: 'deployment-controller',
|
||||||
|
},
|
||||||
|
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
count: 1,
|
||||||
|
type: 'Normal',
|
||||||
|
reportingComponent: 'deployment-controller',
|
||||||
|
reportingInstance: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
name: 'unrelated-pod-123456',
|
||||||
|
namespace: 'default',
|
||||||
|
uid: 'event-uid-3',
|
||||||
|
resourceVersion: '1002',
|
||||||
|
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
involvedObject: {
|
||||||
|
kind: 'Pod',
|
||||||
|
namespace: 'default',
|
||||||
|
name: 'unrelated-pod',
|
||||||
|
uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
|
||||||
|
apiVersion: 'v1',
|
||||||
|
resourceVersion: '2002',
|
||||||
|
},
|
||||||
|
reason: 'Scheduled',
|
||||||
|
message: 'Successfully assigned unrelated-pod to node',
|
||||||
|
source: {
|
||||||
|
component: 'default-scheduler',
|
||||||
|
},
|
||||||
|
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
count: 1,
|
||||||
|
reportingComponent: 'scheduler',
|
||||||
|
reportingInstance: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
mockUseEnvironmentId.mockReturnValue(3);
|
||||||
|
|
||||||
|
const HelmEventsDatatableWithProviders = withTestQueryProvider(
|
||||||
|
withUserProvider(withTestRouter(HelmEventsDatatable), user)
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<HelmEventsDatatableWithProviders
|
||||||
|
namespace="default"
|
||||||
|
releaseResources={testResources}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelmEventsDatatable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||||
|
() => HttpResponse.json(mockEventsResponse)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render events datatable with correct title', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('Events reflect the latest revision only.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly filter related events using the filterRelatedEvents function', () => {
|
||||||
|
const filteredEvents = filterRelatedEvents(
|
||||||
|
mixedEventsResponse.items as Event[],
|
||||||
|
testResources
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filteredEvents.length).toBe(1);
|
||||||
|
expect(filteredEvents[0].involvedObject.uid).toBe('test-deployment-uid');
|
||||||
|
|
||||||
|
const unrelatedEvents = filteredEvents.filter(
|
||||||
|
(e) => e.involvedObject.uid === 'unrelated-pod-uid'
|
||||||
|
);
|
||||||
|
expect(unrelatedEvents.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
import { Event } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||||
|
import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
|
||||||
|
import { useEvents } from '@/react/kubernetes/queries/useEvents';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { GenericResource } from '../../types';
|
||||||
|
|
||||||
|
export const storageKey = 'k8sHelmEventsDatatable';
|
||||||
|
export const settingsStore = createStore(storageKey, {
|
||||||
|
id: 'Date',
|
||||||
|
desc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function HelmEventsDatatable({
|
||||||
|
namespace,
|
||||||
|
releaseResources,
|
||||||
|
}: {
|
||||||
|
namespace: string;
|
||||||
|
releaseResources: GenericResource[];
|
||||||
|
}) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const tableState = useTableState(settingsStore, storageKey);
|
||||||
|
|
||||||
|
const eventsQuery = useEvents(environmentId, {
|
||||||
|
namespace,
|
||||||
|
queryOptions: {
|
||||||
|
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||||
|
select: (data) => filterRelatedEvents(data, releaseResources),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget>
|
||||||
|
<EventsDatatable
|
||||||
|
dataset={eventsQuery.data || []}
|
||||||
|
title={
|
||||||
|
<TextTip inline color="blue" className="!text-xs">
|
||||||
|
Events reflect the latest revision only.
|
||||||
|
</TextTip>
|
||||||
|
}
|
||||||
|
titleIcon={null}
|
||||||
|
tableState={tableState}
|
||||||
|
isLoading={eventsQuery.isInitialLoading}
|
||||||
|
data-cy="k8sAppDetail-eventsTable"
|
||||||
|
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
|
||||||
|
noWidget
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHelmEventsTableState() {
|
||||||
|
return useTableState(settingsStore, storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRelatedEvents(
|
||||||
|
events: Event[],
|
||||||
|
resources: GenericResource[]
|
||||||
|
) {
|
||||||
|
const relatedUids = getReleaseUids(resources);
|
||||||
|
const relatedUidsSet = new Set(relatedUids);
|
||||||
|
return events.filter(
|
||||||
|
(event) =>
|
||||||
|
event.involvedObject.uid && relatedUidsSet.has(event.involvedObject.uid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReleaseUids(resources: GenericResource[]) {
|
||||||
|
return compact(resources.map((resource) => resource.metadata.uid));
|
||||||
|
}
|
|
@ -1,18 +1,58 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { CodeEditor } from '@@/CodeEditor';
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
|
||||||
|
import { DiffViewMode } from './DiffControl';
|
||||||
|
import { DiffViewSection } from './DiffViewSection';
|
||||||
|
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
manifest: string;
|
manifest: string;
|
||||||
|
selectedRevisionNumber: SelectedRevisionNumber;
|
||||||
|
diffViewMode: DiffViewMode;
|
||||||
|
compareManifest?: string;
|
||||||
|
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||||
|
isCompareReleaseLoading: boolean;
|
||||||
|
isCompareReleaseError: boolean;
|
||||||
|
diffControl: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ManifestDetails({ manifest }: Props) {
|
export function ManifestDetails({
|
||||||
|
manifest,
|
||||||
|
selectedRevisionNumber,
|
||||||
|
diffViewMode,
|
||||||
|
compareManifest,
|
||||||
|
compareRevisionNumberFetched,
|
||||||
|
isCompareReleaseLoading,
|
||||||
|
isCompareReleaseError,
|
||||||
|
diffControl,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<CodeEditor
|
<>
|
||||||
id="helm-manifest"
|
{diffControl}
|
||||||
type="yaml"
|
{diffViewMode === 'view' ? (
|
||||||
data-cy="helm-manifest"
|
<CodeEditor
|
||||||
value={manifest}
|
id="helm-manifest"
|
||||||
height="600px"
|
type="yaml"
|
||||||
readonly
|
data-cy="helm-manifest"
|
||||||
/>
|
value={manifest}
|
||||||
|
readonly
|
||||||
|
fileName={`Revision #${selectedRevisionNumber}`}
|
||||||
|
placeholder="No manifest found"
|
||||||
|
height="60vh"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DiffViewSection
|
||||||
|
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||||
|
isCompareReleaseError={isCompareReleaseError}
|
||||||
|
compareRevisionNumberFetched={compareRevisionNumberFetched}
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
newText={manifest}
|
||||||
|
originalText={compareManifest ?? ''}
|
||||||
|
id="helm-manifest-diff-viewer"
|
||||||
|
data-cy="helm-manifest-diff-viewer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,48 @@
|
||||||
import Markdown from 'markdown-to-jsx';
|
import Markdown from 'markdown-to-jsx';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { DiffViewMode } from './DiffControl';
|
||||||
|
import { DiffViewSection } from './DiffViewSection';
|
||||||
|
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
notes: string;
|
notes: string;
|
||||||
|
selectedRevisionNumber: SelectedRevisionNumber;
|
||||||
|
diffViewMode: DiffViewMode;
|
||||||
|
compareNotes?: string;
|
||||||
|
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||||
|
isCompareReleaseLoading: boolean;
|
||||||
|
isCompareReleaseError: boolean;
|
||||||
|
diffControl: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NotesDetails({ notes }: Props) {
|
export function NotesDetails({
|
||||||
return <Markdown className="list-inside mt-6">{notes}</Markdown>;
|
notes,
|
||||||
|
selectedRevisionNumber,
|
||||||
|
diffViewMode,
|
||||||
|
compareNotes,
|
||||||
|
compareRevisionNumberFetched,
|
||||||
|
isCompareReleaseLoading,
|
||||||
|
isCompareReleaseError,
|
||||||
|
diffControl,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{diffControl}
|
||||||
|
{diffViewMode === 'view' ? (
|
||||||
|
<Markdown className="list-inside mt-6">{notes}</Markdown>
|
||||||
|
) : (
|
||||||
|
<DiffViewSection
|
||||||
|
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||||
|
isCompareReleaseError={isCompareReleaseError}
|
||||||
|
compareRevisionNumberFetched={compareRevisionNumberFetched}
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
newText={notes}
|
||||||
|
originalText={compareNotes ?? ''}
|
||||||
|
id="helm-notes-diff-viewer"
|
||||||
|
data-cy="helm-notes-diff-viewer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +1,184 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { compact } from 'lodash';
|
import { compact } from 'lodash';
|
||||||
|
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useEvents } from '@/react/kubernetes/queries/useEvents';
|
||||||
|
|
||||||
import { NavTabs, Option } from '@@/NavTabs';
|
import { NavTabs, Option } from '@@/NavTabs';
|
||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../../types';
|
||||||
|
import { useHelmHistory } from '../queries/useHelmHistory';
|
||||||
|
|
||||||
import { ManifestDetails } from './ManifestDetails';
|
import { ManifestDetails } from './ManifestDetails';
|
||||||
import { NotesDetails } from './NotesDetails';
|
import { NotesDetails } from './NotesDetails';
|
||||||
import { ValuesDetails } from './ValuesDetails';
|
import { ValuesDetails } from './ValuesDetails';
|
||||||
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
|
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
|
||||||
|
import { DiffControl, DiffViewMode } from './DiffControl';
|
||||||
|
import { useHelmReleaseToCompare } from './useHelmReleaseToCompare';
|
||||||
|
import {
|
||||||
|
filterRelatedEvents,
|
||||||
|
HelmEventsDatatable,
|
||||||
|
useHelmEventsTableState,
|
||||||
|
} from './HelmEventsDatatable';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
release: HelmRelease;
|
release: HelmRelease;
|
||||||
|
selectedRevision: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Tab = 'values' | 'notes' | 'manifest' | 'resources';
|
type Tab = 'values' | 'notes' | 'manifest' | 'resources' | 'events';
|
||||||
|
|
||||||
|
export function ReleaseTabs({ release, selectedRevision }: Props) {
|
||||||
|
const {
|
||||||
|
params: { tab },
|
||||||
|
} = useCurrentStateAndParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
// state is here so that the state isn't lost when the tab changes
|
||||||
|
const [isUserSupplied, setIsUserSupplied] = useState(true);
|
||||||
|
// start with NaN so that the input is empty (see <Input /> for more details)
|
||||||
|
const [selectedCompareRevisionNumber, setSelectedCompareRevisionNumber] =
|
||||||
|
useState(NaN);
|
||||||
|
const [diffViewMode, setDiffViewMode] = useState<DiffViewMode>('view');
|
||||||
|
|
||||||
|
const historyQuery = useHelmHistory(
|
||||||
|
environmentId,
|
||||||
|
release.name,
|
||||||
|
release.namespace ?? ''
|
||||||
|
);
|
||||||
|
const earliestRevisionNumber =
|
||||||
|
historyQuery.data?.[historyQuery.data.length - 1]?.version ??
|
||||||
|
release.version ??
|
||||||
|
1;
|
||||||
|
const latestRevisionNumber =
|
||||||
|
historyQuery.data?.[0]?.version ?? release.version ?? 1;
|
||||||
|
const { compareRelease, isCompareReleaseLoading, isCompareReleaseError } =
|
||||||
|
useHelmReleaseToCompare(
|
||||||
|
release,
|
||||||
|
earliestRevisionNumber,
|
||||||
|
latestRevisionNumber,
|
||||||
|
diffViewMode,
|
||||||
|
selectedRevision,
|
||||||
|
selectedCompareRevisionNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
const { autoRefreshRate } = useHelmEventsTableState();
|
||||||
|
const { data: eventWarningCount } = useEvents(environmentId, {
|
||||||
|
namespace: release.namespace ?? '',
|
||||||
|
queryOptions: {
|
||||||
|
autoRefreshRate: autoRefreshRate * 1000,
|
||||||
|
select: (data) => {
|
||||||
|
const relatedEvents = filterRelatedEvents(
|
||||||
|
data,
|
||||||
|
release.info?.resources ?? []
|
||||||
|
);
|
||||||
|
return relatedEvents.filter((e) => e.type === 'Warning').length;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavTabs<Tab>
|
||||||
|
onSelect={setTab}
|
||||||
|
selectedId={parseValidTab(tab, !!release.info?.notes)}
|
||||||
|
type="pills"
|
||||||
|
justified
|
||||||
|
options={helmTabs(
|
||||||
|
release,
|
||||||
|
isUserSupplied,
|
||||||
|
setIsUserSupplied,
|
||||||
|
earliestRevisionNumber,
|
||||||
|
latestRevisionNumber,
|
||||||
|
selectedRevision,
|
||||||
|
selectedCompareRevisionNumber,
|
||||||
|
setSelectedCompareRevisionNumber,
|
||||||
|
diffViewMode,
|
||||||
|
handleDiffViewChange,
|
||||||
|
isCompareReleaseLoading,
|
||||||
|
isCompareReleaseError,
|
||||||
|
eventWarningCount ?? 0,
|
||||||
|
compareRelease
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleDiffViewChange(diffViewMode: DiffViewMode) {
|
||||||
|
setDiffViewMode(diffViewMode);
|
||||||
|
|
||||||
|
if (latestRevisionNumber === earliestRevisionNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the input for compare revision number is NaN, set it to the previous revision number
|
||||||
|
if (
|
||||||
|
Number.isNaN(selectedCompareRevisionNumber) &&
|
||||||
|
diffViewMode === 'specific'
|
||||||
|
) {
|
||||||
|
if (selectedRevision > earliestRevisionNumber) {
|
||||||
|
setSelectedCompareRevisionNumber(selectedRevision - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// it could be useful to compare to the latest revision number if the selected revision number is the earliest revision number
|
||||||
|
setSelectedCompareRevisionNumber(latestRevisionNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(tab: Tab) {
|
||||||
|
router.stateService.go('kubernetes.helm', {
|
||||||
|
tab,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function helmTabs(
|
function helmTabs(
|
||||||
release: HelmRelease,
|
release: HelmRelease,
|
||||||
isUserSupplied: boolean,
|
isUserSupplied: boolean,
|
||||||
setIsUserSupplied: (isUserSupplied: boolean) => void
|
setIsUserSupplied: (isUserSupplied: boolean) => void,
|
||||||
|
earliestRevisionNumber: number,
|
||||||
|
latestRevisionNumber: number,
|
||||||
|
selectedRevisionNumber: number,
|
||||||
|
compareRevisionNumber: number,
|
||||||
|
setCompareRevisionNumber: (compareRevisionNumber: number) => void,
|
||||||
|
diffViewMode: DiffViewMode,
|
||||||
|
setDiffViewMode: (diffViewMode: DiffViewMode) => void,
|
||||||
|
isCompareReleaseLoading: boolean,
|
||||||
|
isCompareReleaseError: boolean,
|
||||||
|
eventWarningCount: number,
|
||||||
|
compareRelease?: HelmRelease
|
||||||
): Option<Tab>[] {
|
): Option<Tab>[] {
|
||||||
|
// as long as the latest revision number is greater than the earliest revision number, there are changes to compare
|
||||||
|
const showDiffControl = latestRevisionNumber > earliestRevisionNumber;
|
||||||
|
|
||||||
return compact([
|
return compact([
|
||||||
{
|
{
|
||||||
label: 'Resources',
|
label: 'Resources',
|
||||||
id: 'resources',
|
id: 'resources',
|
||||||
children: <ResourcesTable />,
|
children: <ResourcesTable />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Events
|
||||||
|
{eventWarningCount >= 1 && (
|
||||||
|
<Badge type="warnSecondary">
|
||||||
|
<Icon icon={AlertTriangle} className="!mr-1" />
|
||||||
|
{eventWarningCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
id: 'events',
|
||||||
|
children: (
|
||||||
|
<HelmEventsDatatable
|
||||||
|
namespace={release.namespace ?? ''}
|
||||||
|
releaseResources={release.info?.resources ?? []}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Values',
|
label: 'Values',
|
||||||
id: 'values',
|
id: 'values',
|
||||||
|
@ -34,35 +186,97 @@ function helmTabs(
|
||||||
<ValuesDetails
|
<ValuesDetails
|
||||||
values={release.values}
|
values={release.values}
|
||||||
isUserSupplied={isUserSupplied}
|
isUserSupplied={isUserSupplied}
|
||||||
setIsUserSupplied={setIsUserSupplied}
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
compareValues={compareRelease?.values}
|
||||||
|
compareRevisionNumberFetched={compareRelease?.version}
|
||||||
|
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||||
|
isCompareReleaseError={isCompareReleaseError}
|
||||||
|
diffControl={
|
||||||
|
<DiffControl
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
latestRevisionNumber={latestRevisionNumber}
|
||||||
|
earliestRevisionNumber={earliestRevisionNumber}
|
||||||
|
compareRevisionNumber={compareRevisionNumber}
|
||||||
|
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
setDiffViewMode={setDiffViewMode}
|
||||||
|
isUserSupplied={isUserSupplied}
|
||||||
|
setIsUserSupplied={setIsUserSupplied}
|
||||||
|
showUserSuppliedCheckbox
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Manifest',
|
label: 'Manifest',
|
||||||
id: 'manifest',
|
id: 'manifest',
|
||||||
children: <ManifestDetails manifest={release.manifest} />,
|
children: (
|
||||||
|
<ManifestDetails
|
||||||
|
manifest={release.manifest}
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
compareManifest={compareRelease?.manifest}
|
||||||
|
compareRevisionNumberFetched={compareRelease?.version}
|
||||||
|
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||||
|
isCompareReleaseError={isCompareReleaseError}
|
||||||
|
diffControl={
|
||||||
|
showDiffControl && (
|
||||||
|
<DiffControl
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
latestRevisionNumber={latestRevisionNumber}
|
||||||
|
earliestRevisionNumber={earliestRevisionNumber}
|
||||||
|
compareRevisionNumber={compareRevisionNumber}
|
||||||
|
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
setDiffViewMode={setDiffViewMode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
!!release.info?.notes && {
|
!!release.info?.notes && {
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
children: <NotesDetails notes={release.info.notes} />,
|
children: (
|
||||||
|
<NotesDetails
|
||||||
|
notes={release.info.notes}
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
compareNotes={compareRelease?.info?.notes}
|
||||||
|
compareRevisionNumberFetched={compareRelease?.version}
|
||||||
|
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||||
|
isCompareReleaseError={isCompareReleaseError}
|
||||||
|
diffControl={
|
||||||
|
showDiffControl && (
|
||||||
|
<DiffControl
|
||||||
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
|
latestRevisionNumber={latestRevisionNumber}
|
||||||
|
earliestRevisionNumber={earliestRevisionNumber}
|
||||||
|
compareRevisionNumber={compareRevisionNumber}
|
||||||
|
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
setDiffViewMode={setDiffViewMode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseTabs({ release }: Props) {
|
function parseValidTab(tab: string, hasNotes: boolean): Tab {
|
||||||
const [tab, setTab] = useState<Tab>('resources');
|
if (
|
||||||
// state is here so that the state isn't lost when the tab changes
|
tab === 'values' ||
|
||||||
const [isUserSupplied, setIsUserSupplied] = useState(true);
|
(tab === 'notes' && hasNotes) ||
|
||||||
|
tab === 'manifest' ||
|
||||||
return (
|
tab === 'resources' ||
|
||||||
<NavTabs<Tab>
|
tab === 'events'
|
||||||
onSelect={setTab}
|
) {
|
||||||
selectedId={tab}
|
return tab;
|
||||||
type="pills"
|
}
|
||||||
justified
|
return 'resources';
|
||||||
options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,17 @@ import { DescribeModal } from './DescribeModal';
|
||||||
|
|
||||||
const mockUseDescribeResource = vi.fn();
|
const mockUseDescribeResource = vi.fn();
|
||||||
|
|
||||||
vi.mock('yaml-schema', () => ({}));
|
// Mock the CodeEditor component instead of yaml-schema
|
||||||
|
vi.mock('@@/CodeEditor', () => ({
|
||||||
|
CodeEditor: ({
|
||||||
|
value,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
'data-cy'?: string;
|
||||||
|
}) => <div data-cy={dataCy}>{value}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./queries/useDescribeResource', () => ({
|
vi.mock('./queries/useDescribeResource', () => ({
|
||||||
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
|
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
import { useHelmRelease } from '../../queries/useHelmRelease';
|
import { useHelmRelease } from '../../queries/useHelmRelease';
|
||||||
|
|
||||||
|
@ -34,12 +35,14 @@ const settingsStore = createStore('helm-resources');
|
||||||
export function ResourcesTable() {
|
export function ResourcesTable() {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
const { params } = useCurrentStateAndParams();
|
const { params } = useCurrentStateAndParams();
|
||||||
const { name, namespace } = params;
|
const { name, namespace, revision } = params;
|
||||||
|
const revisionNumber = revision ? parseInt(revision, 10) : undefined;
|
||||||
|
|
||||||
const tableState = useTableState(settingsStore, storageKey);
|
const tableState = useTableState(settingsStore, storageKey);
|
||||||
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
|
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
|
||||||
showResources: true,
|
showResources: true,
|
||||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||||
|
revision: revisionNumber,
|
||||||
});
|
});
|
||||||
const rows = useResourceRows(helmReleaseQuery.data?.info?.resources);
|
const rows = useResourceRows(helmReleaseQuery.data?.info?.resources);
|
||||||
|
|
||||||
|
@ -48,11 +51,17 @@ export function ResourcesTable() {
|
||||||
<Datatable
|
<Datatable
|
||||||
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
|
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
|
||||||
noWidget
|
noWidget
|
||||||
|
isLoading={helmReleaseQuery.isLoading}
|
||||||
dataset={rows}
|
dataset={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
includeSearch
|
includeSearch
|
||||||
settingsManager={tableState}
|
settingsManager={tableState}
|
||||||
emptyContentLabel="No resources found"
|
emptyContentLabel="No resources found"
|
||||||
|
title={
|
||||||
|
<TextTip inline color="blue" className="!text-xs">
|
||||||
|
Resources reflect the latest revision only.
|
||||||
|
</TextTip>
|
||||||
|
}
|
||||||
disableSelect
|
disableSelect
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
data-cy="helm-resources-datatable"
|
data-cy="helm-resources-datatable"
|
||||||
|
|
|
@ -14,5 +14,9 @@ export const status = columnHelper.accessor((row) => row.status.label, {
|
||||||
|
|
||||||
function Cell({ row }: CellContext<ResourceRow, string>) {
|
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||||
const { status } = row.original;
|
const { status } = row.original;
|
||||||
|
if (!status.label) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
return <StatusBadge color={status.type}>{status.label}</StatusBadge>;
|
return <StatusBadge color={status.type}>{status.label}</StatusBadge>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +1,74 @@
|
||||||
import { Checkbox } from '@@/form-components/Checkbox';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { CodeEditor } from '@@/CodeEditor';
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
|
||||||
import { Values } from '../../types';
|
import { Values } from '../../types';
|
||||||
|
|
||||||
|
import { DiffViewMode } from './DiffControl';
|
||||||
|
import { DiffViewSection } from './DiffViewSection';
|
||||||
|
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
values?: Values;
|
values?: Values;
|
||||||
isUserSupplied: boolean;
|
isUserSupplied: boolean;
|
||||||
setIsUserSupplied: (isUserSupplied: boolean) => void;
|
selectedRevisionNumber: SelectedRevisionNumber;
|
||||||
|
diffViewMode: DiffViewMode;
|
||||||
|
compareValues?: Values;
|
||||||
|
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||||
|
isCompareReleaseLoading: boolean;
|
||||||
|
isCompareReleaseError: boolean;
|
||||||
|
diffControl: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noValuesMessage = 'No values found';
|
|
||||||
|
|
||||||
export function ValuesDetails({
|
export function ValuesDetails({
|
||||||
values,
|
values,
|
||||||
isUserSupplied,
|
isUserSupplied,
|
||||||
setIsUserSupplied,
|
selectedRevisionNumber,
|
||||||
|
diffViewMode,
|
||||||
|
compareValues,
|
||||||
|
compareRevisionNumberFetched,
|
||||||
|
isCompareReleaseLoading,
|
||||||
|
isCompareReleaseError,
|
||||||
|
diffControl,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<>
|
||||||
{/* bring in line with the code editor copy button */}
|
{diffControl}
|
||||||
<div className="absolute top-1 left-0">
|
{diffViewMode === 'view' ? (
|
||||||
<Checkbox
|
<CodeEditor
|
||||||
label="User defined only"
|
type="yaml"
|
||||||
id="values-details-user-supplied"
|
id="values-details-code-editor"
|
||||||
checked={isUserSupplied}
|
data-cy="values-details-code-editor"
|
||||||
onChange={() => setIsUserSupplied(!isUserSupplied)}
|
value={
|
||||||
data-cy="values-details-user-supplied"
|
isUserSupplied
|
||||||
|
? values?.userSuppliedValues ?? ''
|
||||||
|
: values?.computedValues ?? ''
|
||||||
|
}
|
||||||
|
readonly
|
||||||
|
fileName={`Revision #${selectedRevisionNumber}`}
|
||||||
|
placeholder="No values found"
|
||||||
|
height="60vh"
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
<CodeEditor
|
<DiffViewSection
|
||||||
type="yaml"
|
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||||
id="values-details-code-editor"
|
isCompareReleaseError={isCompareReleaseError}
|
||||||
data-cy="values-details-code-editor"
|
compareRevisionNumberFetched={compareRevisionNumberFetched}
|
||||||
value={
|
selectedRevisionNumber={selectedRevisionNumber}
|
||||||
isUserSupplied
|
newText={
|
||||||
? values?.userSuppliedValues ?? noValuesMessage
|
isUserSupplied
|
||||||
: values?.computedValues ?? noValuesMessage
|
? values?.userSuppliedValues ?? ''
|
||||||
}
|
: values?.computedValues ?? ''
|
||||||
readonly
|
}
|
||||||
/>
|
originalText={
|
||||||
</div>
|
isUserSupplied
|
||||||
|
? compareValues?.userSuppliedValues ?? ''
|
||||||
|
: compareValues?.computedValues ?? ''
|
||||||
|
}
|
||||||
|
id="values-details-diff-viewer"
|
||||||
|
data-cy="values-details-diff-viewer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
// exporting as types here allows the JSDocs to be reused, improving readability
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revision number of the latest release.
|
||||||
|
*/
|
||||||
|
export type LatestRevisionNumber = number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revision number selected in the UI.
|
||||||
|
*/
|
||||||
|
export type SelectedRevisionNumber = number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revision number to compare with.
|
||||||
|
*/
|
||||||
|
export type CompareRevisionNumber = number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The earliest revision number available for the chart.
|
||||||
|
*/
|
||||||
|
export type EarliestRevisionNumber = number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revision number that's being fetched (instead of the form state).
|
||||||
|
*/
|
||||||
|
export type CompareRevisionNumberFetched = number;
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../../types';
|
||||||
|
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||||
|
|
||||||
|
import { DiffViewMode } from './DiffControl';
|
||||||
|
|
||||||
|
/** useHelmReleaseToCompare is a hook that returns the release to compare to based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber */
|
||||||
|
export function useHelmReleaseToCompare(
|
||||||
|
release: HelmRelease,
|
||||||
|
earliestRevisionNumber: number,
|
||||||
|
latestRevisionNumber: number,
|
||||||
|
diffViewMode: DiffViewMode,
|
||||||
|
selectedRevisionNumber: number,
|
||||||
|
selectedCompareRevisionNumber: number
|
||||||
|
) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
// the selectedCompareRevisionNumber is the number selected in the input field, but the compareRevisionNumber is the revision number of the release to compare to
|
||||||
|
const compareRevisionNumber = getCompareReleaseVersion(
|
||||||
|
diffViewMode,
|
||||||
|
selectedRevisionNumber,
|
||||||
|
selectedCompareRevisionNumber
|
||||||
|
);
|
||||||
|
const enabled =
|
||||||
|
compareRevisionNumber <= latestRevisionNumber &&
|
||||||
|
compareRevisionNumber >= earliestRevisionNumber;
|
||||||
|
|
||||||
|
// a 1 hour stale time is nice because past releases are not likely to change
|
||||||
|
const compareReleaseQuery = useHelmRelease(
|
||||||
|
environmentId,
|
||||||
|
release.name,
|
||||||
|
release.namespace ?? '',
|
||||||
|
{
|
||||||
|
showResources: false,
|
||||||
|
enabled,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
revision: compareRevisionNumber,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
compareRelease: compareReleaseQuery.data,
|
||||||
|
isCompareReleaseLoading: compareReleaseQuery.isInitialLoading,
|
||||||
|
isCompareReleaseError: compareReleaseQuery.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCompareReleaseVersion is a helper function that returns the revision number that should be fetched based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber
|
||||||
|
function getCompareReleaseVersion(
|
||||||
|
diffViewMode: DiffViewMode,
|
||||||
|
selectedRevisionNumber: number,
|
||||||
|
selectedCompareRevisionNumber: number
|
||||||
|
) {
|
||||||
|
if (diffViewMode === 'previous') {
|
||||||
|
return selectedRevisionNumber - 1;
|
||||||
|
}
|
||||||
|
if (diffViewMode === 'specific') {
|
||||||
|
return selectedCompareRevisionNumber;
|
||||||
|
}
|
||||||
|
return selectedRevisionNumber;
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
|
export function useHelmHistory(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
name: string,
|
||||||
|
namespace: string
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'helm', 'releases', namespace, name, 'history'],
|
||||||
|
() => getHelmHistory(environmentId, name, namespace),
|
||||||
|
{
|
||||||
|
enabled: !!environmentId && !!name && !!namespace,
|
||||||
|
...withGlobalError('Unable to retrieve helm application history'),
|
||||||
|
retry: 3,
|
||||||
|
// occasionally the application shows before the release is created, take some more time to refetch
|
||||||
|
retryDelay: 2000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHelmHistory(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
name: string,
|
||||||
|
namespace: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<HelmRelease[]>(
|
||||||
|
`endpoints/${environmentId}/kubernetes/helm/${name}/history`,
|
||||||
|
{
|
||||||
|
params: { namespace },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error, 'Unable to retrieve helm application history');
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,15 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
|
type Options<T> = {
|
||||||
|
select?: (data: HelmRelease) => T;
|
||||||
|
showResources?: boolean;
|
||||||
|
refetchInterval?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
staleTime?: number;
|
||||||
|
/** when the revision is undefined, the latest revision is fetched */
|
||||||
|
revision?: number;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* React hook to fetch a specific Helm release
|
* React hook to fetch a specific Helm release
|
||||||
*/
|
*/
|
||||||
|
@ -13,39 +22,52 @@ export function useHelmRelease<T = HelmRelease>(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
name: string,
|
name: string,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
options: {
|
options: Options<T> = {}
|
||||||
select?: (data: HelmRelease) => T;
|
|
||||||
showResources?: boolean;
|
|
||||||
refetchInterval?: number;
|
|
||||||
} = {}
|
|
||||||
) {
|
) {
|
||||||
const { select, showResources, refetchInterval } = options;
|
const { select, showResources, refetchInterval, revision, staleTime } =
|
||||||
|
options;
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[environmentId, 'helm', 'releases', namespace, name, options.showResources],
|
[
|
||||||
|
environmentId,
|
||||||
|
'helm',
|
||||||
|
'releases',
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
revision,
|
||||||
|
showResources,
|
||||||
|
],
|
||||||
() =>
|
() =>
|
||||||
getHelmRelease(environmentId, name, {
|
getHelmRelease(environmentId, name, {
|
||||||
namespace,
|
namespace,
|
||||||
showResources,
|
showResources,
|
||||||
|
revision,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
enabled: !!environmentId && !!name && !!namespace,
|
enabled: !!environmentId && !!name && !!namespace && options.enabled,
|
||||||
...withGlobalError('Unable to retrieve helm application details'),
|
...withGlobalError('Unable to retrieve helm application details'),
|
||||||
|
retry: 3,
|
||||||
|
// occasionally the application shows before the release is created, take some more time to refetch
|
||||||
|
retryDelay: 2000,
|
||||||
select,
|
select,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
|
staleTime,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
namespace: string;
|
||||||
|
showResources?: boolean;
|
||||||
|
revision?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific Helm release
|
* Get a specific Helm release
|
||||||
*/
|
*/
|
||||||
async function getHelmRelease(
|
async function getHelmRelease(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
name: string,
|
name: string,
|
||||||
params: {
|
params: Params
|
||||||
namespace: string;
|
|
||||||
showResources?: boolean;
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<HelmRelease>(
|
const { data } = await axios.get<HelmRelease>(
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { compact, flatMap } from 'lodash';
|
||||||
|
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { getHelmRepositories } from '../../queries/useHelmChartList';
|
||||||
|
|
||||||
|
interface HelmSearch {
|
||||||
|
entries: Entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entries {
|
||||||
|
[key: string]: { version: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartVersion {
|
||||||
|
Repo: string;
|
||||||
|
Version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all Helm repositories for the current user
|
||||||
|
*/
|
||||||
|
export function useHelmRepositories() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
return useQuery(
|
||||||
|
['helm', 'repositories'],
|
||||||
|
async () => getHelmRepositories(user.Id),
|
||||||
|
{
|
||||||
|
enabled: !!user.Id,
|
||||||
|
...withGlobalError('Unable to retrieve helm repositories'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to get a list of available versions for a chart from specified repositories
|
||||||
|
*
|
||||||
|
* @param chart The chart name to get versions for
|
||||||
|
* @param repositories Array of repository URLs to search in
|
||||||
|
*/
|
||||||
|
export function useHelmRepoVersions(
|
||||||
|
chart: string,
|
||||||
|
staleTime: number,
|
||||||
|
repositories: string[] = []
|
||||||
|
) {
|
||||||
|
// Fetch versions from each repository in parallel as separate queries
|
||||||
|
const versionQueries = useQueries({
|
||||||
|
queries: useMemo(
|
||||||
|
() =>
|
||||||
|
repositories.map((repo) => ({
|
||||||
|
queryKey: ['helm', 'repositories', chart, repo],
|
||||||
|
queryFn: () => getSearchHelmRepo(repo, chart),
|
||||||
|
enabled: !!chart && repositories.length > 0,
|
||||||
|
staleTime,
|
||||||
|
...withGlobalError(`Unable to retrieve versions from ${repo}`),
|
||||||
|
})),
|
||||||
|
[repositories, chart, staleTime]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine the results from all repositories for easier consumption
|
||||||
|
const allVersions = useMemo(() => {
|
||||||
|
const successfulResults = compact(versionQueries.map((q) => q.data));
|
||||||
|
return flatMap(successfulResults);
|
||||||
|
}, [versionQueries]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: allVersions,
|
||||||
|
isInitialLoading: versionQueries.some((q) => q.isLoading),
|
||||||
|
isError: versionQueries.some((q) => q.isError),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Helm repositories for user
|
||||||
|
*/
|
||||||
|
async function getSearchHelmRepo(
|
||||||
|
repo: string,
|
||||||
|
chart: string
|
||||||
|
): Promise<ChartVersion[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<HelmSearch>(`templates/helm`, {
|
||||||
|
params: { repo, chart },
|
||||||
|
});
|
||||||
|
const versions = data.entries[chart];
|
||||||
|
return (
|
||||||
|
versions?.map((v) => ({
|
||||||
|
Repo: repo,
|
||||||
|
Version: v.version,
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
|
export interface UpdateHelmReleasePayload {
|
||||||
|
namespace: string;
|
||||||
|
values?: string;
|
||||||
|
repo?: string;
|
||||||
|
name: string;
|
||||||
|
chart: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateHelmReleasePayload) =>
|
||||||
|
updateHelmRelease(environmentId, payload),
|
||||||
|
...withInvalidate(queryClient, [
|
||||||
|
[environmentId, 'helm', 'releases'],
|
||||||
|
applicationsQueryKeys.applications(environmentId),
|
||||||
|
]),
|
||||||
|
...withGlobalError('Unable to uninstall helm application'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateHelmRelease(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
payload: UpdateHelmReleasePayload
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<HelmRelease>(
|
||||||
|
`endpoints/${environmentId}/kubernetes/helm`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to update helm release');
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,8 +3,11 @@ import { useState } from 'react';
|
||||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
|
import {
|
||||||
|
useHelmChartList,
|
||||||
|
useHelmRepositories,
|
||||||
|
} from '../queries/useHelmChartList';
|
||||||
|
|
||||||
import { useHelmChartList } from './queries/useHelmChartList';
|
|
||||||
import { HelmTemplatesList } from './HelmTemplatesList';
|
import { HelmTemplatesList } from './HelmTemplatesList';
|
||||||
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
||||||
|
|
||||||
|
@ -18,10 +21,8 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
||||||
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
|
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
|
||||||
|
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data: charts = [], isLoading: chartsLoading } = useHelmChartList(
|
const helmReposQuery = useHelmRepositories(user.Id);
|
||||||
user.Id
|
const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []);
|
||||||
);
|
|
||||||
|
|
||||||
function clearHelmChart() {
|
function clearHelmChart() {
|
||||||
setSelectedChart(null);
|
setSelectedChart(null);
|
||||||
onSelectHelmChart('');
|
onSelectHelmChart('');
|
||||||
|
@ -44,9 +45,9 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HelmTemplatesList
|
<HelmTemplatesList
|
||||||
charts={charts}
|
charts={chartListQuery.data}
|
||||||
selectAction={handleChartSelection}
|
selectAction={handleChartSelection}
|
||||||
loading={chartsLoading}
|
isLoading={chartListQuery.isInitialLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -50,7 +50,7 @@ function renderComponent({
|
||||||
withUserProvider(
|
withUserProvider(
|
||||||
withTestRouter(() => (
|
withTestRouter(() => (
|
||||||
<HelmTemplatesList
|
<HelmTemplatesList
|
||||||
loading={loading}
|
isLoading={loading}
|
||||||
charts={charts}
|
charts={charts}
|
||||||
selectAction={selectAction}
|
selectAction={selectAction}
|
||||||
/>
|
/>
|
||||||
|
@ -137,10 +137,10 @@ describe('HelmTemplatesList', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show loading message when loading prop is true', async () => {
|
it('should show loading message when loading prop is true', async () => {
|
||||||
renderComponent({ loading: true });
|
renderComponent({ loading: true, charts: [] });
|
||||||
|
|
||||||
// Check for loading message
|
// Check for loading message
|
||||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
expect(screen.getByText('Loading helm charts...')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Initial download of Helm charts can take a few minutes')
|
screen.getByText('Initial download of Helm charts can take a few minutes')
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
|
@ -5,13 +5,14 @@ import { Link } from '@/react/components/Link';
|
||||||
|
|
||||||
import { InsightsBox } from '@@/InsightsBox';
|
import { InsightsBox } from '@@/InsightsBox';
|
||||||
import { SearchBar } from '@@/datatables/SearchBar';
|
import { SearchBar } from '@@/datatables/SearchBar';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
|
|
||||||
import { HelmTemplatesListItem } from './HelmTemplatesListItem';
|
import { HelmTemplatesListItem } from './HelmTemplatesListItem';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading: boolean;
|
isLoading: boolean;
|
||||||
charts?: Chart[];
|
charts?: Chart[];
|
||||||
selectAction: (chart: Chart) => void;
|
selectAction: (chart: Chart) => void;
|
||||||
}
|
}
|
||||||
|
@ -70,7 +71,7 @@ function getFilteredCharts(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HelmTemplatesList({
|
export function HelmTemplatesList({
|
||||||
loading,
|
isLoading,
|
||||||
charts = [],
|
charts = [],
|
||||||
selectAction,
|
selectAction,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
@ -159,16 +160,20 @@ export function HelmTemplatesList({
|
||||||
<div className="text-muted small mt-4">No Helm charts found</div>
|
<div className="text-muted small mt-4">No Helm charts found</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && (
|
{isLoading && (
|
||||||
<div className="text-muted text-center">
|
<div className="flex flex-col">
|
||||||
Loading...
|
<InlineLoader className="justify-center">
|
||||||
<div className="text-muted text-center">
|
Loading helm charts...
|
||||||
Initial download of Helm charts can take a few minutes
|
</InlineLoader>
|
||||||
</div>
|
{charts.length === 0 && (
|
||||||
|
<div className="text-muted text-center">
|
||||||
|
Initial download of Helm charts can take a few minutes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && charts.length === 0 && (
|
{!isLoading && charts.length === 0 && (
|
||||||
<div className="text-muted text-center">
|
<div className="text-muted text-center">
|
||||||
No helm charts available.
|
No helm charts available.
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -106,10 +106,7 @@ describe('HelmTemplatesSelectedItem', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
// First show the editor
|
// Verify editor is visible by default
|
||||||
await user.click(await screen.findByText('Custom values'));
|
|
||||||
|
|
||||||
// Verify editor is visible
|
|
||||||
expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
|
expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
|
||||||
|
|
||||||
// Now hide the editor
|
// Now hide the editor
|
||||||
|
|
|
@ -143,7 +143,12 @@ export function HelmTemplatesSelectedItem({
|
||||||
{({ values, setFieldValue }) => (
|
{({ values, setFieldValue }) => (
|
||||||
<Form className="form-horizontal">
|
<Form className="form-horizontal">
|
||||||
<div className="form-group !m-0">
|
<div className="form-group !m-0">
|
||||||
<FormSection title="Custom values" isFoldable className="mt-4">
|
<FormSection
|
||||||
|
title="Custom values"
|
||||||
|
isFoldable
|
||||||
|
defaultFolded={false}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
{loadingValues && (
|
{loadingValues && (
|
||||||
<div className="col-sm-12 p-0">
|
<div className="col-sm-12 p-0">
|
||||||
<InlineLoader>Loading values.yaml...</InlineLoader>
|
<InlineLoader>Loading values.yaml...</InlineLoader>
|
||||||
|
@ -156,7 +161,7 @@ export function HelmTemplatesSelectedItem({
|
||||||
onChange={(value) => setFieldValue('values', value)}
|
onChange={(value) => setFieldValue('values', value)}
|
||||||
type="yaml"
|
type="yaml"
|
||||||
data-cy="helm-app-creation-editor"
|
data-cy="helm-app-creation-editor"
|
||||||
placeholder="Define or paste the content of your values yaml file here"
|
textTip="Define or paste the content of your values yaml file here"
|
||||||
>
|
>
|
||||||
You can get more information about Helm values file format
|
You can get more information about Helm values file format
|
||||||
in the{' '}
|
in the{' '}
|
||||||
|
|
|
@ -1,79 +0,0 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { compact } from 'lodash';
|
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|
||||||
import { withGlobalError } from '@/react-tools/react-query';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Chart,
|
|
||||||
HelmChartsResponse,
|
|
||||||
HelmRepositoriesResponse,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
async function getHelmRepositories(userId: number): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<HelmRepositoriesResponse>(
|
|
||||||
`users/${userId}/helm/repositories`
|
|
||||||
);
|
|
||||||
const { GlobalRepository, UserRepositories } = response.data;
|
|
||||||
|
|
||||||
// Extract URLs from user repositories
|
|
||||||
const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
|
|
||||||
|
|
||||||
// Combine global and user repositories, remove duplicates and empty values
|
|
||||||
const uniqueHelmRepos = [
|
|
||||||
...new Set([GlobalRepository, ...userHelmReposUrls]),
|
|
||||||
]
|
|
||||||
.map((url) => url.toLowerCase())
|
|
||||||
.filter((url) => url);
|
|
||||||
|
|
||||||
return uniqueHelmRepos;
|
|
||||||
} catch (err) {
|
|
||||||
throw parseAxiosError(err, 'Failed to fetch Helm repositories');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getChartsFromRepo(repo: string): Promise<Chart[]> {
|
|
||||||
try {
|
|
||||||
// Construct the URL with required repo parameter
|
|
||||||
const response = await axios.get<HelmChartsResponse>('templates/helm', {
|
|
||||||
params: { repo },
|
|
||||||
});
|
|
||||||
|
|
||||||
return compact(
|
|
||||||
Object.values(response.data.entries).map((versions) =>
|
|
||||||
versions[0] ? { ...versions[0], repo } : null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors from chart repositories as some may error but others may not
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCharts(userId: number): Promise<Chart[]> {
|
|
||||||
try {
|
|
||||||
// First, get all the helm repositories
|
|
||||||
const repos = await getHelmRepositories(userId);
|
|
||||||
|
|
||||||
// Then fetch charts from each repository in parallel
|
|
||||||
const chartsPromises = repos.map((repo) => getChartsFromRepo(repo));
|
|
||||||
const chartsArrays = await Promise.all(chartsPromises);
|
|
||||||
|
|
||||||
// Flatten the arrays of charts into a single array
|
|
||||||
return chartsArrays.flat();
|
|
||||||
} catch (err) {
|
|
||||||
throw parseAxiosError(err, 'Failed to fetch Helm charts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React hook to fetch helm charts from all accessible repositories
|
|
||||||
* @param userId User ID
|
|
||||||
*/
|
|
||||||
export function useHelmChartList(userId: number) {
|
|
||||||
return useQuery([userId, 'helm-charts'], () => getCharts(userId), {
|
|
||||||
enabled: !!userId,
|
|
||||||
...withGlobalError('Unable to retrieve Helm charts'),
|
|
||||||
});
|
|
||||||
}
|
|
48
app/react/kubernetes/helm/helm-status-utils.ts
Normal file
48
app/react/kubernetes/helm/helm-status-utils.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
export enum DeploymentStatus {
|
||||||
|
DEPLOYED = 'deployed',
|
||||||
|
FAILED = 'failed',
|
||||||
|
PENDING = 'pending-install',
|
||||||
|
PENDINGUPGRADE = 'pending-upgrade',
|
||||||
|
PENDINGROLLBACK = 'pending-rollback',
|
||||||
|
SUPERSEDED = 'superseded',
|
||||||
|
UNINSTALLED = 'uninstalled',
|
||||||
|
UNINSTALLING = 'uninstalling',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusColor(status?: string) {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case DeploymentStatus.DEPLOYED:
|
||||||
|
return 'success';
|
||||||
|
case DeploymentStatus.FAILED:
|
||||||
|
return 'danger';
|
||||||
|
case DeploymentStatus.PENDING:
|
||||||
|
case DeploymentStatus.PENDINGUPGRADE:
|
||||||
|
case DeploymentStatus.PENDINGROLLBACK:
|
||||||
|
case DeploymentStatus.UNINSTALLING:
|
||||||
|
return 'warn';
|
||||||
|
case DeploymentStatus.SUPERSEDED:
|
||||||
|
default:
|
||||||
|
return 'muted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusText(status?: string) {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case DeploymentStatus.DEPLOYED:
|
||||||
|
return 'Deployed';
|
||||||
|
case DeploymentStatus.FAILED:
|
||||||
|
return 'Failed';
|
||||||
|
case DeploymentStatus.PENDING:
|
||||||
|
return 'Pending install';
|
||||||
|
case DeploymentStatus.PENDINGUPGRADE:
|
||||||
|
return 'Pending upgrade';
|
||||||
|
case DeploymentStatus.PENDINGROLLBACK:
|
||||||
|
return 'Pending rollback';
|
||||||
|
case DeploymentStatus.UNINSTALLING:
|
||||||
|
return 'Uninstalling';
|
||||||
|
case DeploymentStatus.SUPERSEDED:
|
||||||
|
return 'Superseded';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
105
app/react/kubernetes/helm/queries/useHelmChartList.ts
Normal file
105
app/react/kubernetes/helm/queries/useHelmChartList.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||||
|
import { compact, flatMap } from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
import { Chart, HelmChartsResponse, HelmRepositoriesResponse } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Helm repositories for user
|
||||||
|
*/
|
||||||
|
export async function getHelmRepositories(userId: UserId) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<HelmRepositoriesResponse>(
|
||||||
|
`users/${userId}/helm/repositories`
|
||||||
|
);
|
||||||
|
const repos = compact([
|
||||||
|
// compact will remove the global repository if it's empty
|
||||||
|
data.GlobalRepository.toLowerCase(),
|
||||||
|
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
|
||||||
|
]);
|
||||||
|
return [...new Set(repos)];
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChartsFromRepo(repo: string): Promise<Chart[]> {
|
||||||
|
try {
|
||||||
|
// Construct the URL with required repo parameter
|
||||||
|
const response = await axios.get<HelmChartsResponse>('templates/helm', {
|
||||||
|
params: { repo },
|
||||||
|
});
|
||||||
|
|
||||||
|
return compact(
|
||||||
|
Object.values(response.data.entries).map((versions) =>
|
||||||
|
versions[0] ? { ...versions[0], repo } : null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors from chart repositories as some may error but others may not
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all accessible Helm repositories for a user
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @returns Query result with list of repository URLs
|
||||||
|
*/
|
||||||
|
export function useHelmRepositories(userId: number) {
|
||||||
|
return useQuery(
|
||||||
|
[userId, 'helm-repositories'],
|
||||||
|
() => getHelmRepositories(userId),
|
||||||
|
{
|
||||||
|
enabled: !!userId,
|
||||||
|
...withGlobalError('Unable to retrieve Helm repositories'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to fetch helm charts from the provided repositories
|
||||||
|
* Charts from each repository are loaded independently, allowing the UI
|
||||||
|
* to show charts as they become available instead of waiting for all
|
||||||
|
* repositories to load
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param repositories List of repository URLs to fetch charts from
|
||||||
|
*/
|
||||||
|
export function useHelmChartList(userId: number, repositories: string[] = []) {
|
||||||
|
// Fetch charts from each repository in parallel as separate queries
|
||||||
|
const chartQueries = useQueries({
|
||||||
|
queries: useMemo(
|
||||||
|
() =>
|
||||||
|
repositories.map((repo) => ({
|
||||||
|
queryKey: [userId, repo, 'helm-charts'],
|
||||||
|
queryFn: () => getChartsFromRepo(repo),
|
||||||
|
enabled: !!userId && repositories.length > 0,
|
||||||
|
// one request takes a long time, so fail early to get feedback to the user faster
|
||||||
|
retries: false,
|
||||||
|
...withGlobalError(`Unable to retrieve Helm charts from ${repo}`),
|
||||||
|
})),
|
||||||
|
[repositories, userId]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine the results for easier consumption by components
|
||||||
|
const allCharts = useMemo(
|
||||||
|
() => flatMap(compact(chartQueries.map((q) => q.data))),
|
||||||
|
[chartQueries]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Data from all repositories that have loaded so far
|
||||||
|
data: allCharts,
|
||||||
|
// Overall loading state
|
||||||
|
isInitialLoading: chartQueries.some((q) => q.isInitialLoading),
|
||||||
|
// Overall error state
|
||||||
|
isError: chartQueries.some((q) => q.isError),
|
||||||
|
};
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ export interface GenericResource {
|
||||||
metadata: {
|
metadata: {
|
||||||
name: string;
|
name: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
|
uid?: string;
|
||||||
};
|
};
|
||||||
status: ResourceStatus;
|
status: ResourceStatus;
|
||||||
}
|
}
|
||||||
|
@ -29,6 +30,7 @@ export interface HelmRelease {
|
||||||
notes?: string;
|
notes?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
resources?: GenericResource[];
|
resources?: GenericResource[];
|
||||||
|
last_deployed: string;
|
||||||
};
|
};
|
||||||
/** The chart that was released */
|
/** The chart that was released */
|
||||||
chart: HelmChart;
|
chart: HelmChart;
|
||||||
|
@ -104,10 +106,10 @@ export interface HelmChartsResponse {
|
||||||
generated: string;
|
generated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InstallChartPayload = {
|
export interface InstallChartPayload {
|
||||||
Name: string;
|
Name: string;
|
||||||
Repo: string;
|
Repo: string;
|
||||||
Chart: string;
|
Chart: string;
|
||||||
Values: string;
|
Values: string;
|
||||||
Namespace: string;
|
Namespace: string;
|
||||||
};
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue