From 4ee349bd6b68f47e54898e5684bbfc3ce8f01b79 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Tue, 13 May 2025 22:15:04 +1200 Subject: [PATCH] feat(helm): helm actions [r8s-259] (#715) Co-authored-by: James Player Co-authored-by: Cara Ryan Co-authored-by: stevensbkang --- api/http/handler/helm/helm_install.go | 4 +- app/assets/css/app.css | 6 +- app/assets/css/bootstrap-override.css | 4 +- app/assets/css/theme.css | 4 +- .../views/configs/create/createconfig.html | 2 +- app/docker/views/images/build/buildimage.html | 2 +- app/kubernetes/__module.js | 2 +- .../kubernetesConfigurationData.html | 2 +- .../components/code-editor/code-editor.html | 2 +- .../components/code-editor/code-editor.js | 2 +- .../form-components/web-editor-form/index.js | 2 +- .../web-editor-form/web-editor-form.html | 2 +- app/portainer/react/components/index.ts | 4 +- .../views/stacks/create/createstack.html | 2 +- app/portainer/views/stacks/edit/stack.html | 2 +- app/react/common/date-utils.ts | 16 + app/react/components/Badge/Badge.stories.tsx | 2 + app/react/components/Badge/Badge.tsx | 8 +- .../Blocklist/BlocklistItem.stories.tsx | 75 +++++ app/react/components/Card/Card.tsx | 2 +- app/react/components/CodeEditor.tsx | 228 ------------- .../{ => CodeEditor}/CodeEditor.module.css | 46 ++- .../{ => CodeEditor}/CodeEditor.test.tsx | 29 +- .../components/CodeEditor/CodeEditor.tsx | 158 +++++++++ .../components/CodeEditor/DiffViewer.test.tsx | 101 ++++++ .../components/CodeEditor/DiffViewer.tsx | 138 ++++++++ .../components/CodeEditor/FileNameHeader.tsx | 71 ++++ app/react/components/CodeEditor/index.ts | 1 + .../CodeEditor/useCodeEditorExtensions.ts | 91 +++++ .../components/InlineLoader/InlineLoader.tsx | 10 +- .../components/RadioGroup/RadioGroup.tsx | 23 +- app/react/components/Sheet.tsx | 159 +++++++++ app/react/components/WebEditorForm.tsx | 2 + app/react/components/Widget/WidgetIcon.tsx | 11 + app/react/components/Widget/WidgetTitle.tsx | 12 +- .../buttons/CopyButton/CopyButton.stories.tsx | 4 +- app/react/components/datatables/Datatable.tsx | 32 +- .../components/datatables/DatatableHeader.tsx | 4 +- .../components/datatables/TableContainer.tsx | 4 +- .../components/datatables/TableTitle.tsx | 2 +- .../AdvancedMode.tsx | 2 +- .../components/modals/Modal/Modal.module.css | 6 - app/react/components/modals/Modal/Modal.tsx | 10 +- .../CreateView/CreateEdgeJobForm.tsx | 2 +- .../UpdateEdgeJobForm/UpdateEdgeJobForm.tsx | 2 +- .../CreateView/DockerContentField.tsx | 2 +- .../CreateView/KubeManifestForm.tsx | 2 +- .../EditEdgeStackForm/ComposeForm.tsx | 2 +- .../EditEdgeStackForm/KubernetesForm.tsx | 2 +- app/react/hooks/useDebounce.ts | 12 +- .../ApplicationEventsDatatable.tsx | 31 +- .../ApplicationsDatatable.tsx | 1 + .../ListView/ApplicationsDatatable/SubRow.tsx | 5 +- .../components/EditYamlFormSection.tsx | 2 +- .../EventsDatatable/EventsDatatable.tsx | 9 +- .../EventsDatatable/columns/eventType.tsx | 12 + .../EventsDatatable/columns/index.ts | 3 +- .../EventsDatatable/columns/kind.tsx | 13 + .../EventsDatatable/columns/name.tsx | 16 + .../kubernetes/components/YAMLInspector.tsx | 2 +- .../ChartActions/ChartActions.tsx | 44 ++- .../ChartActions/RollbackButton.test.tsx | 8 +- .../ChartActions/RollbackButton.tsx | 25 +- .../ChartActions/UpgradeButton.test.tsx | 182 ++++++++++ .../ChartActions/UpgradeButton.tsx | 167 +++++++++ .../ChartActions/UpgradeHelmModal.tsx | 151 +++++++++ .../HelmApplicationView.test.tsx | 316 +++++++++++++----- .../HelmApplicationView.tsx | 103 ++++-- .../HelmRevisionItem.test.tsx | 116 +++++++ .../HelmApplicationView/HelmRevisionItem.tsx | 49 +++ .../HelmApplicationView/HelmRevisionList.tsx | 43 +++ .../HelmRevisionListSheet.tsx | 40 +++ .../helm/HelmApplicationView/HelmSummary.tsx | 70 +--- .../ReleaseDetails/DiffControl.test.tsx | 193 +++++++++++ .../ReleaseDetails/DiffControl.tsx | 146 ++++++++ .../ReleaseDetails/DiffViewSection.tsx | 55 +++ .../HelmEventsDatatable.test.tsx | 242 ++++++++++++++ .../ReleaseDetails/HelmEventsDatatable.tsx | 77 +++++ .../ReleaseDetails/ManifestDetails.tsx | 58 +++- .../ReleaseDetails/NotesDetails.tsx | 43 ++- .../ReleaseDetails/ReleaseTabs.tsx | 252 ++++++++++++-- .../ResourcesTable/DescribeModal.test.tsx | 12 +- .../ResourcesTable/ResourcesTable.tsx | 11 +- .../ResourcesTable/columns/status.tsx | 4 + .../ReleaseDetails/ValuesDetails.tsx | 84 +++-- .../ReleaseDetails/types.ts | 26 ++ .../ReleaseDetails/useHelmReleaseToCompare.ts | 60 ++++ .../queries/useHelmHistory.ts | 44 +++ .../queries/useHelmRelease.ts | 46 ++- .../queries/useHelmRepositories.ts | 99 ++++++ .../queries/useUpdateHelmReleaseMutation.ts | 44 +++ .../helm/HelmTemplates/HelmTemplates.tsx | 15 +- .../HelmTemplates/HelmTemplatesList.test.tsx | 6 +- .../helm/HelmTemplates/HelmTemplatesList.tsx | 23 +- .../HelmTemplatesSelectedItem.test.tsx | 5 +- .../HelmTemplatesSelectedItem.tsx | 9 +- .../HelmTemplates/queries/useHelmChartList.ts | 79 ----- .../kubernetes/helm/helm-status-utils.ts | 48 +++ .../helm/queries/useHelmChartList.ts | 105 ++++++ app/react/kubernetes/helm/types.ts | 6 +- app/react/kubernetes/queries/useEvents.ts | 20 +- .../HelmRepositoryDatatable.tsx | 1 + .../custom-templates/CreateView/InnerForm.tsx | 2 +- .../custom-templates/EditView/InnerForm.tsx | 2 +- .../DeployForm.tsx | 2 +- app/setup-tests/mock-codemirror.tsx | 15 + app/setup-tests/mock-localizeDate.ts | 18 + package.json | 4 + pkg/libhelm/options/install_options.go | 1 + pkg/libhelm/sdk/common.go | 3 +- pkg/libhelm/sdk/get.go | 10 +- pkg/libhelm/sdk/history.go | 7 + pkg/libhelm/sdk/install.go | 3 +- pkg/libhelm/sdk/upgrade.go | 2 +- pkg/libhelm/sdk/values.go | 11 +- tailwind.config.js | 1 + yarn.lock | 242 +++++++++++++- 117 files changed, 4161 insertions(+), 696 deletions(-) create mode 100644 app/react/common/date-utils.ts create mode 100644 app/react/components/Blocklist/BlocklistItem.stories.tsx delete mode 100644 app/react/components/CodeEditor.tsx rename app/react/components/{ => CodeEditor}/CodeEditor.module.css (78%) rename app/react/components/{ => CodeEditor}/CodeEditor.test.tsx (78%) create mode 100644 app/react/components/CodeEditor/CodeEditor.tsx create mode 100644 app/react/components/CodeEditor/DiffViewer.test.tsx create mode 100644 app/react/components/CodeEditor/DiffViewer.tsx create mode 100644 app/react/components/CodeEditor/FileNameHeader.tsx create mode 100644 app/react/components/CodeEditor/index.ts create mode 100644 app/react/components/CodeEditor/useCodeEditorExtensions.ts create mode 100644 app/react/components/Sheet.tsx create mode 100644 app/react/components/Widget/WidgetIcon.tsx create mode 100644 app/react/kubernetes/components/EventsDatatable/columns/name.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts delete mode 100644 app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts create mode 100644 app/react/kubernetes/helm/helm-status-utils.ts create mode 100644 app/react/kubernetes/helm/queries/useHelmChartList.ts create mode 100644 app/setup-tests/mock-localizeDate.ts diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go index d7254c1f4..cd4985770 100644 --- a/api/http/handler/helm/helm_install.go +++ b/api/http/handler/helm/helm_install.go @@ -26,6 +26,7 @@ type installChartPayload struct { Chart string `json:"chart"` Repo string `json:"repo"` Values string `json:"values"` + Version string `json:"version"` } var errChartNameInvalid = errors.New("invalid chart name. " + @@ -101,6 +102,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r installOpts := options.InstallOptions{ Name: p.Name, Chart: p.Chart, + Version: p.Version, Namespace: p.Namespace, Repo: p.Repo, KubernetesClusterAccess: clusterAccess, @@ -192,7 +194,7 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte, g := new(errgroup.Group) for _, resource := range yamlResources { g.Go(func() error { - tmpfile, err := os.CreateTemp("", "helm-manifest-*") + tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml") if err != nil { return errors.Wrap(err, "failed to create a tmp helm manifest file") } diff --git a/app/assets/css/app.css b/app/assets/css/app.css index afcf49470..85d8fbca5 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -32,6 +32,10 @@ body { color: var(--text-body-color) !important; } +.bg-widget-color { + background: var(--bg-widget-color); +} + html, body, #page-wrapper, @@ -224,7 +228,7 @@ input[type='checkbox'] { .blocklist-item--selected { 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); } diff --git a/app/assets/css/bootstrap-override.css b/app/assets/css/bootstrap-override.css index 19e63397d..cbded5622 100644 --- a/app/assets/css/bootstrap-override.css +++ b/app/assets/css/bootstrap-override.css @@ -20,9 +20,7 @@ } .vertical-center { - display: inline-flex; - align-items: center; - gap: 5px; + @apply inline-flex items-center gap-1; } .flex-center { diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index f104f3e7b..eb2d36882 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -268,7 +268,7 @@ --bg-body-color: var(--grey-2); --bg-btn-default-color: var(--grey-3); --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-checkbox-border-color: var(--grey-8); --bg-code-color: var(--grey-2); @@ -388,7 +388,7 @@ --border-navtabs-color: var(--grey-38); --border-pre-color: var(--grey-3); --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-hover-color: var(--grey-3); --border-panel-color: var(--grey-2); diff --git a/app/docker/views/configs/create/createconfig.html b/app/docker/views/configs/create/createconfig.html index 945330c06..6ac0bef06 100644 --- a/app/docker/views/configs/create/createconfig.html +++ b/app/docker/views/configs/create/createconfig.html @@ -18,7 +18,7 @@
diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 98607e569..adb937cce 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -145,7 +145,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo const helmApplication = { name: 'kubernetes.helm', - url: '/helm/:namespace/:name', + url: '/helm/:namespace/:name?revision&tab', views: { 'content@': { component: 'kubernetesHelmApplicationView', diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html index ca2caebef..193ce8be0 100644 --- a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html @@ -165,7 +165,7 @@ value="$ctrl.formValues.DataYaml" on-change="($ctrl.editorUpdate)" 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" >
diff --git a/app/portainer/components/code-editor/code-editor.html b/app/portainer/components/code-editor/code-editor.html index 2cf42b272..446da5dab 100644 --- a/app/portainer/components/code-editor/code-editor.html +++ b/app/portainer/components/code-editor/code-editor.html @@ -1,6 +1,6 @@ diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 14c5c8628..8ecf54e06 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -160,7 +160,7 @@ {message[type]}; } diff --git a/app/react/components/Badge/Badge.tsx b/app/react/components/Badge/Badge.tsx index e31e3b122..5babb5733 100644 --- a/app/react/components/Badge/Badge.tsx +++ b/app/react/components/Badge/Badge.tsx @@ -9,7 +9,8 @@ export type BadgeType = | 'successSecondary' | 'dangerSecondary' | 'warnSecondary' - | 'infoSecondary'; + | 'infoSecondary' + | 'muted'; // the classes are typed in full because tailwind doesn't render the interpolated classes const typeClasses: Record = { @@ -54,6 +55,11 @@ const typeClasses: Record = { 'th-dark:text-blue-3 th-dark: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 { diff --git a/app/react/components/Blocklist/BlocklistItem.stories.tsx b/app/react/components/Blocklist/BlocklistItem.stories.tsx new file mode 100644 index 000000000..e38d1933e --- /dev/null +++ b/app/react/components/Blocklist/BlocklistItem.stories.tsx @@ -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 = { + title: 'Components/Blocklist/BlocklistItem', + component: BlocklistItem, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +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: ( +
+
+ Deployed + Revision #4 +
+
+ my-app-1.0.0 + + {localizeDate(new Date('2000-01-01'))} + +
+
+ ), + }, +}; + +export const MultipleItems: Story = { + render: () => ( +
+ First Item + Second Item (Selected) + Third Item +
+ ), +}; diff --git a/app/react/components/Card/Card.tsx b/app/react/components/Card/Card.tsx index 1839f649e..b5e8bf5f1 100644 --- a/app/react/components/Card/Card.tsx +++ b/app/react/components/Card/Card.tsx @@ -10,7 +10,7 @@ export function Card({ className, children }: PropsWithChildren) {
{children} diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx deleted file mode 100644 index e74a70ec9..000000000 --- a/app/react/components/CodeEditor.tsx +++ /dev/null @@ -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 = { - 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 ( - <> -
-
-
- {!!placeholder && {placeholder}} -
- -
- - Copy to clipboard - -
-
- {versions && ( -
-
- -
-
- )} -
- - - ); -} diff --git a/app/react/components/CodeEditor.module.css b/app/react/components/CodeEditor/CodeEditor.module.css similarity index 78% rename from app/react/components/CodeEditor.module.css rename to app/react/components/CodeEditor/CodeEditor.module.css index 4936a56e4..2bf9cae88 100644 --- a/app/react/components/CodeEditor.module.css +++ b/app/react/components/CodeEditor/CodeEditor.module.css @@ -47,17 +47,33 @@ .root :global(.cm-editor .cm-gutters) { 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) { text-align: left; } -.root :global(.cm-editor), -.root :global(.cm-editor .cm-scroller) { +.codeEditor :global(.cm-editor), +.codeEditor :global(.cm-editor .cm-scroller) { 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 */ /* 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) { @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: ''; +} diff --git a/app/react/components/CodeEditor.test.tsx b/app/react/components/CodeEditor/CodeEditor.test.tsx similarity index 78% rename from app/react/components/CodeEditor.test.tsx rename to app/react/components/CodeEditor/CodeEditor.test.tsx index 1d1a3ae85..7b100b0e3 100644 --- a/app/react/components/CodeEditor.test.tsx +++ b/app/react/components/CodeEditor/CodeEditor.test.tsx @@ -1,9 +1,21 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Extension } from '@codemirror/state'; 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 = { id: 'test-editor', @@ -24,7 +36,7 @@ test('should render with basic props', () => { test('should display placeholder when provided', async () => { const placeholder = 'Enter your code here'; const { findByText } = render( - + ); const placeholderText = await findByText(placeholder); @@ -44,7 +56,7 @@ test('should show copy button and copy content', async () => { clipboard: mockClipboard, }); - const copyButton = await findByText('Copy to clipboard'); + const copyButton = await findByText('Copy'); expect(copyButton).toBeVisible(); await userEvent.click(copyButton); @@ -113,3 +125,14 @@ test('should apply custom height', async () => { const editor = (await findByRole('textbox')).parentElement?.parentElement; 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( + + ); + + expect(await findByText(fileName)).toBeInTheDocument(); + expect(await findByText(testValue)).toBeInTheDocument(); +}); diff --git a/app/react/components/CodeEditor/CodeEditor.tsx b/app/react/components/CodeEditor/CodeEditor.tsx new file mode 100644 index 000000000..3df9f4193 --- /dev/null +++ b/app/react/components/CodeEditor/CodeEditor.tsx @@ -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 ( + <> +
+
+
+ {!!textTip && {textTip}} +
+ {/* the copy button is in the file name header, when fileName is provided */} + {!fileName && ( +
+ + Copy + +
+ )} +
+ {versions && ( +
+
+ +
+
+ )} +
+
+ {fileName && ( + + + + )} + +
+ + ); +} diff --git a/app/react/components/CodeEditor/DiffViewer.test.tsx b/app/react/components/CodeEditor/DiffViewer.test.tsx new file mode 100644 index 000000000..9e8b1b8ec --- /dev/null +++ b/app/react/components/CodeEditor/DiffViewer.test.tsx @@ -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: () =>
, + oneDarkHighlightStyle: {}, + keymap: { + of: () => ({}), + }, +})); + +// Mock react-codemirror-merge +vi.mock('react-codemirror-merge', () => { + function CodeMirrorMerge({ children }: { children: React.ReactNode }) { + return
{children}
; + } + function Original({ value }: { value: string }) { + return
{value}
; + } + function Modified({ value }: { value: string }) { + return
{value}
; + } + + 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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}` + ); + }); +}); diff --git a/app/react/components/CodeEditor/DiffViewer.tsx b/app/react/components/CodeEditor/DiffViewer.tsx new file mode 100644 index 000000000..9a66407af --- /dev/null +++ b/app/react/components/CodeEditor/DiffViewer.tsx @@ -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 ( +
+ {hasFileNames && ( + + )} + {/* 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 */} +
+ .cm-scroller]:!min-h-[var(--editor-min-height)]' + )} + id={id} + data-cy={dataCy} + collapseUnchanged={defaultCollapseUnchanged} + > + + + +
+
+ ); +} + +function DiffFileNameHeaders({ + originalCopyText, + modifiedCopyText, + originalFileName, + modifiedFileName, +}: { + originalCopyText: string; + modifiedCopyText: string; + originalFileName: string; + modifiedFileName: string; +}) { + return ( + +
+ +
+
+
+ +
+ + ); +} diff --git a/app/react/components/CodeEditor/FileNameHeader.tsx b/app/react/components/CodeEditor/FileNameHeader.tsx new file mode 100644 index 000000000..eb3418f2a --- /dev/null +++ b/app/react/components/CodeEditor/FileNameHeader.tsx @@ -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 components (and optional dividers). + */ +export function FileNameHeaderRow({ + children, + className, + style, +}: { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + return ( +
+ {children} +
+ ); +} + +/** + * 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 ( +
+ {fileName} + + Copy + +
+ ); +} diff --git a/app/react/components/CodeEditor/index.ts b/app/react/components/CodeEditor/index.ts new file mode 100644 index 000000000..aa4b20325 --- /dev/null +++ b/app/react/components/CodeEditor/index.ts @@ -0,0 +1 @@ +export * from './CodeEditor'; diff --git a/app/react/components/CodeEditor/useCodeEditorExtensions.ts b/app/react/components/CodeEditor/useCodeEditorExtensions.ts new file mode 100644 index 000000000..3b46a543e --- /dev/null +++ b/app/react/components/CodeEditor/useCodeEditorExtensions.ts @@ -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]); +} diff --git a/app/react/components/InlineLoader/InlineLoader.tsx b/app/react/components/InlineLoader/InlineLoader.tsx index b5d75eae6..d91354de7 100644 --- a/app/react/components/InlineLoader/InlineLoader.tsx +++ b/app/react/components/InlineLoader/InlineLoader.tsx @@ -13,21 +13,21 @@ export type Props = { }; const sizeStyles: Record = { - xs: 'text-xs', - sm: 'text-sm', - md: 'text-md', + xs: 'text-xs gap-1', + sm: 'text-sm gap-2', + md: 'text-md gap-2', }; export function InlineLoader({ children, className, size = 'sm' }: Props) { return (
- + {children}
); diff --git a/app/react/components/RadioGroup/RadioGroup.tsx b/app/react/components/RadioGroup/RadioGroup.tsx index 4380d1d9c..5aacf03c0 100644 --- a/app/react/components/RadioGroup/RadioGroup.tsx +++ b/app/react/components/RadioGroup/RadioGroup.tsx @@ -1,10 +1,19 @@ -import { Option } from '@@/form-components/PortainerSelect'; +import { ReactNode } from 'react'; + +// allow custom labels +export interface RadioGroupOption { + value: TValue; + label: ReactNode; + disabled?: boolean; +} interface Props { - options: Array> | ReadonlyArray>; + options: Array> | ReadonlyArray>; selectedOption: T; name: string; onOptionChange: (value: T) => void; + groupClassName?: string; + itemClassName?: string; } export function RadioGroup({ @@ -12,13 +21,18 @@ export function RadioGroup({ selectedOption, name, onOptionChange, + groupClassName, + itemClassName, }: Props) { return ( -
+
{options.map((option) => ( diff --git a/app/react/components/Sheet.tsx b/app/react/components/Sheet.tsx new file mode 100644 index 000000000..3d5983877 --- /dev/null +++ b/app/react/components/Sheet.tsx @@ -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; +}; + +// similar to the PageHeader component with simplified props and no breadcrumbs +function SheetHeader({ + onReload, + title, + children, +}: PropsWithChildren) { + return ( +
+
+
+ + {title} + + {onReload ? ( + + ) : null} +
+ {children} +
+
+ ); +} + +const SheetOverlay = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, 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, + VariantProps { + showCloseButton?: boolean; +} + +const SheetContent = forwardRef< + ElementRef, + SheetContentProps +>( + ( + { + side = 'right', + className, + children, + title, + showCloseButton = true, + ...props + }, + ref + ) => ( + + + + {title ? : null} + {children} + {showCloseButton && ( + + + + )} + + + ) +); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetDescription, + SheetHeader, +}; diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index 222a4552f..75031a454 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -74,6 +74,7 @@ export function WebEditorForm({ children, error, schema, + textTip, ...props }: PropsWithChildren) { return ( @@ -99,6 +100,7 @@ export function WebEditorForm({ id={id} type="yaml" schema={schema as JSONSchema7} + textTip={textTip} // eslint-disable-next-line react/jsx-props-no-spreading {...props} /> diff --git a/app/react/components/Widget/WidgetIcon.tsx b/app/react/components/Widget/WidgetIcon.tsx new file mode 100644 index 000000000..0309d185e --- /dev/null +++ b/app/react/components/Widget/WidgetIcon.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react'; + +import { Icon } from '@@/Icon'; + +export function WidgetIcon({ icon }: { icon: ReactNode }) { + return ( +
+ +
+ ); +} diff --git a/app/react/components/Widget/WidgetTitle.tsx b/app/react/components/Widget/WidgetTitle.tsx index 2623c374c..3ed390e33 100644 --- a/app/react/components/Widget/WidgetTitle.tsx +++ b/app/react/components/Widget/WidgetTitle.tsx @@ -1,9 +1,7 @@ import clsx from 'clsx'; import { PropsWithChildren, ReactNode } from 'react'; -import { Icon } from '@/react/components/Icon'; - -import { useWidgetContext } from './Widget'; +import { WidgetIcon } from './WidgetIcon'; interface Props { title: ReactNode; @@ -17,16 +15,12 @@ export function WidgetTitle({ className, children, }: PropsWithChildren) { - useWidgetContext(); - return (
-
- -
-

{title}

+ +

{title}

{children}
diff --git a/app/react/components/buttons/CopyButton/CopyButton.stories.tsx b/app/react/components/buttons/CopyButton/CopyButton.stories.tsx index 42368f9b0..2a3df593b 100644 --- a/app/react/components/buttons/CopyButton/CopyButton.stories.tsx +++ b/app/react/components/buttons/CopyButton/CopyButton.stories.tsx @@ -26,13 +26,13 @@ function Template({ export const Primary: Story> = Template.bind({}); Primary.args = { - children: 'Copy to clipboard', + children: 'Copy', copyText: 'this will be copied to clipboard', }; export const NoCopyText: Story> = Template.bind({}); NoCopyText.args = { - children: 'Copy to clipboard without copied text', + children: 'Copy without copied text', copyText: 'clipboard override', displayText: '', }; diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index 2d0b2dfa8..dff56aaf6 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -58,7 +58,7 @@ export interface Props extends AutomationTestingProps { getRowId?(row: D): string; isRowSelectable?(row: Row): boolean; emptyContentLabel?: string; - title?: string; + title?: React.ReactNode; titleIcon?: IconProps['icon']; titleId?: string; initialTableState?: Partial; @@ -71,6 +71,8 @@ export interface Props extends AutomationTestingProps { noWidget?: boolean; extendTableOptions?: (options: TableOptions) => TableOptions; includeSearch?: boolean; + ariaLabel?: string; + id?: string; } export function Datatable({ @@ -100,6 +102,8 @@ export function Datatable({ isServerSidePagination = false, extendTableOptions = (value) => value, includeSearch, + ariaLabel, + id, }: Props & PaginationProps) { const pageCount = useMemo( () => Math.ceil(totalCount / settings.pageSize), @@ -181,9 +185,14 @@ export function Datatable({ () => _.difference(selectedItems, filteredItems), [selectedItems, filteredItems] ); + const { titleAriaLabel, contentAriaLabel } = getAriaLabels( + ariaLabel, + title, + titleId + ); return ( - + ({ isLoading={isLoading} onSortChange={handleSortChange} data-cy={dataCy} - aria-label={`${title} table`} + aria-label={contentAriaLabel} /> ({ } } +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( row: Row, highlightedItemId?: string diff --git a/app/react/components/datatables/DatatableHeader.tsx b/app/react/components/datatables/DatatableHeader.tsx index 810f13c7a..bc0f56763 100644 --- a/app/react/components/datatables/DatatableHeader.tsx +++ b/app/react/components/datatables/DatatableHeader.tsx @@ -8,7 +8,7 @@ import { SearchBar } from './SearchBar'; import { Table } from './Table'; type Props = { - title?: string; + title?: React.ReactNode; titleIcon?: IconProps['icon']; searchValue: string; onSearchChange(value: string): void; @@ -52,7 +52,7 @@ export function DatatableHeader({ return ( ) { if (noWidget) { return ( @@ -25,7 +27,7 @@ export function TableContainer({
- + {children}
diff --git a/app/react/components/datatables/TableTitle.tsx b/app/react/components/datatables/TableTitle.tsx index e253a8b66..feaf529bb 100644 --- a/app/react/components/datatables/TableTitle.tsx +++ b/app/react/components/datatables/TableTitle.tsx @@ -5,7 +5,7 @@ import { Icon } from '@@/Icon'; interface Props { icon?: ReactNode | ComponentType; - label: string; + label: React.ReactNode; description?: ReactNode; className?: string; id?: string; diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx b/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx index 7deac19ee..54bc3e691 100644 --- a/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx @@ -43,7 +43,7 @@ export function AdvancedMode({ id="environment-variables-editor" value={editorValue} onChange={handleEditorChange} - placeholder="e.g. key=value" + textTip="e.g. key=value" data-cy={dataCy} /> diff --git a/app/react/components/modals/Modal/Modal.module.css b/app/react/components/modals/Modal/Modal.module.css index f6ab6bb4b..73bf935d7 100644 --- a/app/react/components/modals/Modal/Modal.module.css +++ b/app/react/components/modals/Modal/Modal.module.css @@ -10,14 +10,8 @@ .modal-content { background-color: var(--bg-modal-content-color); - padding: 20px; - - position: relative; border: 1px solid rgba(0, 0, 0, 0.2); - border-radius: 6px; - - outline: 0; box-shadow: 0 5px 15px rgb(0 0 0 / 50%); } diff --git a/app/react/components/modals/Modal/Modal.tsx b/app/react/components/modals/Modal/Modal.tsx index 1f2cb729b..0d1b0319c 100644 --- a/app/react/components/modals/Modal/Modal.tsx +++ b/app/react/components/modals/Modal/Modal.tsx @@ -39,7 +39,7 @@ export function Modal({ isOpen className={clsx( styles.overlay, - 'z-50 flex items-center justify-center' + 'flex items-center justify-center z-50' )} onDismiss={onDismiss} role="dialog" @@ -56,7 +56,13 @@ export function Modal({ } )} > -
+
{children} {onDismiss && }
diff --git a/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx index 3fad4a8da..e871d197d 100644 --- a/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx +++ b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx @@ -86,7 +86,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) { id="edge-job-editor" onChange={(value) => setFieldValue('fileContent', value)} 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" error={errors.fileContent} /> diff --git a/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx index e25684b97..54ae5dc60 100644 --- a/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx +++ b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx @@ -82,7 +82,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) { id="edge-job-editor" onChange={(value) => setFieldValue('fileContent', value)} 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" error={errors.fileContent} /> diff --git a/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx b/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx index 2f24354bb..7758b353a 100644 --- a/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx +++ b/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx @@ -28,7 +28,7 @@ export function DockerContentField({ value={value} onChange={onChange} 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} readonly={readonly} schema={dockerComposeSchemaQuery.data} diff --git a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx index 1c516d86e..4b2d3504f 100644 --- a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx +++ b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx @@ -74,7 +74,7 @@ export function KubeManifestForm({ value={values.fileContent} onChange={(value) => handleChange({ fileContent: value })} 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} data-cy="stack-creation-editor" > diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx index 806d793ab..8d3fb22d8 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx @@ -66,7 +66,7 @@ export function ComposeForm({ type="yaml" schema={dockerComposeSchema} 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)} error={errors.content} readonly={hasKubeEndpoint} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx index e84acade5..a493ef131 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx @@ -37,7 +37,7 @@ export function KubernetesForm({ value={values.content} type="yaml" 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) => handleContentChange(DeploymentType.Kubernetes, value) } diff --git a/app/react/hooks/useDebounce.ts b/app/react/hooks/useDebounce.ts index 271b0158c..4125df8ff 100644 --- a/app/react/hooks/useDebounce.ts +++ b/app/react/hooks/useDebounce.ts @@ -34,19 +34,23 @@ import { useState, useRef, useCallback, useEffect } from 'react'; // // return ( handleChange(e.target.value)} />) // } -export function useDebounce(value: string, onChange: (value: string) => void) { +export function useDebounce( + value: T, + onChange: (value: T) => void, + delay = 300 +) { const [debouncedValue, setDebouncedValue] = useState(value); // Do not change. See notes above const onChangeDebouncer = useRef( debounce( - (value: string, onChangeFunc: (v: string) => void) => onChangeFunc(value), - 300 + (value: T, onChangeFunc: (v: T) => void) => onChangeFunc(value), + delay ) ); const handleChange = useCallback( - (value: string) => { + (value: T) => { setDebouncedValue(value); onChangeDebouncer.current(value, onChange); }, diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx index f578dc355..8f4289f74 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx @@ -1,5 +1,6 @@ import { useCurrentStateAndParams } from '@uirouter/react'; import { useMemo } from 'react'; +import { compact } from 'lodash'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { EnvironmentId } from '@/react/portainer/environments/types'; @@ -74,36 +75,32 @@ export function useApplicationEvents( 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, { namespace, queryOptions: { autoRefreshRate: options?.autoRefreshRate ? options.autoRefreshRate * 1000 : 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 = applicationQuery.isInitialLoading || servicesQuery.isInitialLoading || podsQuery.isInitialLoading || eventsQuery.isInitialLoading; - return { relatedEvents, isInitialLoading }; + return { relatedEvents: events || [], isInitialLoading }; } diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx index 4e6712939..091e2f97e 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx @@ -96,6 +96,7 @@ export function ApplicationsDatatable({ item={row.original} hideStacks={hideStacks} areSecretsRestricted={!!restrictSecretsQuery.data} + selectDisabled={!hasWriteAuth} /> )} renderTableActions={(selectedItems) => diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx index ce4b5d4ec..6a32865b0 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx @@ -11,19 +11,22 @@ export function SubRow({ item, hideStacks, areSecretsRestricted, + selectDisabled, }: { item: ApplicationRowData; hideStacks: boolean; areSecretsRestricted: boolean; + selectDisabled: boolean; }) { const { user: { Username: username }, } = useCurrentUser(); const colSpan = hideStacks ? 7 : 8; + const alignColSpan = selectDisabled ? 1 : 2; return ( - + {item.KubernetesApplications ? ( } onChange={(values) => onChange(values)} 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" />
diff --git a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx index d2f705c5f..ffb92e7bd 100644 --- a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx +++ b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx @@ -1,5 +1,6 @@ import { Event } from 'kubernetes-types/core/v1'; import { History } from 'lucide-react'; +import { ReactNode } from 'react'; import { IndexOptional } from '@/react/kubernetes/configs/types'; import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; @@ -16,6 +17,8 @@ type Props = { isLoading: boolean; 'data-cy': string; noWidget?: boolean; + title?: ReactNode; + titleIcon?: ReactNode; }; export function EventsDatatable({ @@ -24,6 +27,8 @@ export function EventsDatatable({ isLoading, 'data-cy': dataCy, noWidget, + title = 'Events', + titleIcon = History, }: Props) { return ( > @@ -31,8 +36,8 @@ export function EventsDatatable({ columns={columns} settingsManager={tableState} isLoading={isLoading} - title="Events" - titleIcon={History} + title={title} + titleIcon={titleIcon} getRowId={(row) => row.metadata?.uid || ''} disableSelect renderTableSettings={() => ( diff --git a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx index 47b15a544..0d381d682 100644 --- a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx +++ b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx @@ -1,4 +1,8 @@ +import { Row } from '@tanstack/react-table'; +import { Event } from 'kubernetes-types/core/v1'; + import { Badge, BadgeType } from '@@/Badge'; +import { filterHOC } from '@@/datatables/Filter'; import { columnHelper } from './helper'; @@ -7,6 +11,14 @@ export const eventType = columnHelper.accessor('type', { cell: ({ getValue }) => ( {getValue()} ), + + meta: { + filter: filterHOC('Filter by event type'), + }, + enableColumnFilter: true, + filterFn: (row: Row, _: string, filterValue: string[]) => + filterValue.length === 0 || + (!!row.original.type && filterValue.includes(row.original.type)), }); function getBadgeColor(status?: string): BadgeType { diff --git a/app/react/kubernetes/components/EventsDatatable/columns/index.ts b/app/react/kubernetes/components/EventsDatatable/columns/index.ts index 05ae483d5..f49658090 100644 --- a/app/react/kubernetes/components/EventsDatatable/columns/index.ts +++ b/app/react/kubernetes/components/EventsDatatable/columns/index.ts @@ -2,5 +2,6 @@ import { date } from './date'; import { kind } from './kind'; import { eventType } from './eventType'; import { message } from './message'; +import { name } from './name'; -export const columns = [date, kind, eventType, message]; +export const columns = [date, name, kind, eventType, message]; diff --git a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx index 856d6bf8b..641662291 100644 --- a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx +++ b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx @@ -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'; export const kind = columnHelper.accessor( (event) => event.involvedObject.kind, { header: 'Kind', + meta: { + filter: filterHOC('Filter by kind'), + }, + enableColumnFilter: true, + filterFn: (row: Row, _: string, filterValue: string[]) => + filterValue.length === 0 || + (!!row.original.involvedObject.kind && + filterValue.includes(row.original.involvedObject.kind)), } ); diff --git a/app/react/kubernetes/components/EventsDatatable/columns/name.tsx b/app/react/kubernetes/components/EventsDatatable/columns/name.tsx new file mode 100644 index 000000000..26bc0c8db --- /dev/null +++ b/app/react/kubernetes/components/EventsDatatable/columns/name.tsx @@ -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 ( + + {name} + + ); + }, + } +); diff --git a/app/react/kubernetes/components/YAMLInspector.tsx b/app/react/kubernetes/components/YAMLInspector.tsx index 5baadf3a7..9e678e9f5 100644 --- a/app/react/kubernetes/components/YAMLInspector.tsx +++ b/app/react/kubernetes/components/YAMLInspector.tsx @@ -29,7 +29,7 @@ export function YAMLInspector({ void; +}; export function ChartActions({ environmentId, releaseName, namespace, - currentRevision, -}: { - environmentId: EnvironmentId; - releaseName: string; - namespace?: string; - currentRevision?: number; -}) { - const hasPreviousRevision = currentRevision && currentRevision >= 2; + latestRevision, + earlistRevision, + selectedRevision, + release, + updateRelease, +}: Props) { + const showRollbackButton = + latestRevision && earlistRevision && latestRevision > earlistRevision; return ( -
+
+ - {hasPreviousRevision && ( + {showRollbackButton && ( ({ function renderButton(props = {}) { const defaultProps = { latestRevision: 3, // So we're rolling back to revision 2 + selectedRevision: 3, // This simulates the selectedRevision from URL params environmentId: 1, releaseName: 'test-release', namespace: 'default', ...props, }; - const Wrapped = withTestQueryProvider(RollbackButton); + const Wrapped = withTestQueryProvider( + withTestRouter(RollbackButton, { + route: '/?revision=3', + }) + ); return render(); } diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx index 3d870d3a8..ea8a79bac 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx @@ -1,4 +1,5 @@ import { RotateCcw } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { notifySuccess } from '@/portainer/services/notifications'; @@ -12,6 +13,7 @@ import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation'; type Props = { latestRevision: number; + selectedRevision?: number; environmentId: EnvironmentId; releaseName: string; namespace?: string; @@ -19,13 +21,16 @@ type Props = { export function RollbackButton({ latestRevision, + selectedRevision, environmentId, releaseName, namespace, }: Props) { - // the selectedRevision can be a prop when selecting a revision is implemented - const selectedRevision = latestRevision ? latestRevision - 1 : undefined; - + // when the latest revision is selected, rollback to the previous revision + // otherwise, rollback to the selected revision + const rollbackRevision = + selectedRevision === latestRevision ? latestRevision - 1 : selectedRevision; + const router = useRouter(); const rollbackMutation = useHelmRollbackMutation(environmentId); return ( @@ -38,7 +43,7 @@ export function RollbackButton({ color="default" size="medium" > - Rollback to #{selectedRevision} + Rollback to #{rollbackRevision} ); @@ -47,7 +52,7 @@ export function RollbackButton({ title: 'Are you sure?', modalType: ModalType.Warn, 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) { return; @@ -56,14 +61,20 @@ export function RollbackButton({ rollbackMutation.mutate( { releaseName, - params: { namespace, revision: selectedRevision }, + params: { namespace, revision: rollbackRevision }, }, { onSuccess: () => { notifySuccess( '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, + }); }, } ); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx new file mode 100644 index 000000000..4bea0bf47 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx @@ -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(); +} + +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(); + }); +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx new file mode 100644 index 000000000..4c385c388 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx @@ -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 ( +
+ openUpgradeForm(versions, release)} + disabled={ + versions.length === 0 || + isInitialLoading || + isError || + release?.info?.status?.startsWith('pending') + } + loadingText="Upgrading..." + isLoading={updateHelmReleaseMutation.isLoading} + icon={ArrowUp} + size="medium" + > + Upgrade + + {versions.length === 0 && isInitialLoading && ( + + Checking for new versions... + + )} + {versions.length === 0 && !isInitialLoading && !isError && ( + + No versions available + + 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{' '} + + Helm repositories settings + +
+ } + /> + + )} + {isNewVersionAvailable && ( + + New version available ({latestVersionAvailable}) + + )} +
+ ); + + 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, + }); + }, + }); + } +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx new file mode 100644 index 000000000..bb71f28c5 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx @@ -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; + values: UpdateHelmReleasePayload; + versions: ChartVersion[]; +} + +export function UpgradeHelmModal({ values, versions, onSubmit }: Props) { + const versionOptions: Option[] = 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(defaultVersion); + const [userValues, setUserValues] = useState(values.values || ''); + + return ( + onSubmit()} + size="lg" + className="flex flex-col h-[80vh] px-0" + aria-label="upgrade-helm" + > + } + /> +
+ + + + value={version} + options={versionOptions} + onChange={(version) => { + if (version) { + setVersion(version); + } + }} + data-cy="helm-version-input" + /> + + + + + + + + + setUserValues(value)} + height="50vh" + type="yaml" + data-cy="helm-user-values-editor" + placeholder="Define or paste the content of your values yaml file here" + /> + + +
+
+ + + + +
+
+ ); +} + +export async function openUpgradeHelmModal( + values: UpdateHelmReleasePayload, + versions: ChartVersion[] +) { + return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), { + values, + versions, + }); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx index c0e6b8d71..89f522d3a 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { HttpResponse } from 'msw'; 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 { withUserProvider } from '@/react/test-utils/withUserProvider'; import { mockCodeMirror } from '@/setup-tests/mock-codemirror'; +import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate'; import { HelmApplicationView } from './HelmApplicationView'; // Mock the necessary hooks and dependencies const mockUseCurrentStateAndParams = vi.fn(); const mockUseEnvironmentId = vi.fn(); +mockLocalizeDate(); vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ ...(await importOriginal()), @@ -32,32 +34,109 @@ const minimalHelmRelease = { chart: { metadata: { name: 'test-chart', - // appVersion: '1.0.0', // can be missing for a minimal release version: '2.2.2', }, }, info: { status: 'deployed', + last_deployed: '2021-01-01T00:00:00Z', // notes: 'This is a test note', // can be missing for a minimal release }, manifest: 'This is a test manifest', }; -const helmReleaseWithAdditionalDetails = { - ...minimalHelmRelease, - info: { - ...minimalHelmRelease.info, - notes: 'This is a test note', - }, +// Create a more complete helm release object for testing +const completeHelmRelease = { + name: 'test-release', + version: '1', + namespace: 'default', chart: { - ...minimalHelmRelease.chart, metadata: { - ...minimalHelmRelease.chart.metadata, + name: 'test-chart', 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() { const user = new UserViewModel({ Username: 'user' }); const Wrapped = withTestQueryProvider( @@ -66,92 +145,171 @@ function renderComponent() { return render(); } -describe('HelmApplicationView', () => { - beforeEach(() => { - // Set up default mock values - mockUseEnvironmentId.mockReturnValue(3); - mockUseCurrentStateAndParams.mockReturnValue({ - params: { - name: 'test-release', - namespace: 'default', - }, +describe( + 'HelmApplicationView', + () => { + beforeEach(() => { + // Set up default mock values + mockUseEnvironmentId.mockReturnValue(3); + mockUseCurrentStateAndParams.mockReturnValue({ + params: { + name: 'test-release', + namespace: 'default', + }, + }); }); - }); - it('should display helm release details for minimal release when data is loaded', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); + it('should display helm release details for minimal release when data is loaded', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); - server.use( - http.get('/api/endpoints/3/kubernetes/helm/test-release', () => - HttpResponse.json(minimalHelmRelease) - ) - ); + server.use( + http.get('/api/endpoints/3/kubernetes/helm/test-release', () => + 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 - expect(await findByText('Helm details')).toBeInTheDocument(); + // Check for the page header + expect(await findByText('Helm details')).toBeInTheDocument(); - // Check for the badge content - expect(await findByText(/Namespace/)).toBeInTheDocument(); - expect(await findByText(/Chart version:/)).toBeInTheDocument(); - expect(await findByText(/Chart:/)).toBeInTheDocument(); - expect(await findByText(/Revision/)).toBeInTheDocument(); + // Check for the badge content + expect(await findByText(/Namespace: default/)).toBeInTheDocument(); + expect( + await findByText(/Chart version: test-chart-2.2.2/) + ).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 - expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge - expect(await findAllByText(/test-chart/)).toHaveLength(2); + // There shouldn't be a notes tab when there are no notes + expect(screen.queryByText(/Notes/)).not.toBeInTheDocument(); - // There shouldn't be a notes tab when there are no notes - expect(screen.queryByText(/Notes/)).not.toBeInTheDocument(); + // There shouldn't be an app version badge when it's missing + expect(screen.queryByText(/App version/)).not.toBeInTheDocument(); - // There shouldn't be an app version badge when it's missing - expect(screen.queryByText(/App version/)).not.toBeInTheDocument(); + // Ensure there are no console errors + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); - // Ensure there are no console errors - // eslint-disable-next-line no-console - expect(console.error).not.toHaveBeenCalled(); + // Restore console.error + vi.spyOn(console, 'error').mockRestore(); + }); - // Restore console.error - vi.spyOn(console, 'error').mockRestore(); - }); + it('should display error message when API request fails', async () => { + // 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 API failure - server.use( - http.get('/api/endpoints/3/kubernetes/helm/test-release', () => - HttpResponse.error() - ) - ); + // Mock console.error to prevent test output pollution + vi.spyOn(console, 'error').mockImplementation(() => {}); - // Mock console.error to prevent test output pollution - vi.spyOn(console, 'error').mockImplementation(() => {}); + renderComponent(); - 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 - expect( - await screen.findByText('Failed to load Helm application details') - ).toBeInTheDocument(); + // Restore console.error + vi.spyOn(console, 'error').mockRestore(); + }); - // Restore console.error - vi.spyOn(console, 'error').mockRestore(); - }); + it('should display additional details when available in helm release', async () => { + 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 () => { - server.use( - http.get('/api/endpoints/3/kubernetes/helm/test-release', () => - HttpResponse.json(helmReleaseWithAdditionalDetails) - ) - ); + const { findByText } = renderComponent(); - const { findByText } = renderComponent(); + expect(await findByText('Helm details')).toBeInTheDocument(); - // Check for the notes tab when notes are available - expect(await findByText(/Notes/)).toBeInTheDocument(); + // Check for the app version badge when it's available + await waitFor(() => { + expect( + screen.getByText(/App version/, { exact: false }) + ).toBeInTheDocument(); + }); - // Check for the app version badge when it's available - expect(await findByText(/App version/)).toBeInTheDocument(); - expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument(); - }); -}); + await waitFor(() => { + // Look for specific tab text + 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, + } +); diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx index 164943223..625bb55fe 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx @@ -1,4 +1,5 @@ import { useCurrentStateAndParams } from '@uirouter/react'; +import { useQueryClient } from '@tanstack/react-query'; import helm from '@/assets/ico/vendor/helm.svg?c'; import { PageHeader } from '@/react/components/PageHeader'; @@ -15,14 +16,25 @@ import { HelmSummary } from './HelmSummary'; import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs'; import { useHelmRelease } from './queries/useHelmRelease'; import { ChartActions } from './ChartActions/ChartActions'; +import { HelmRevisionList } from './HelmRevisionList'; +import { HelmRevisionListSheet } from './HelmRevisionListSheet'; +import { useHelmHistory } from './queries/useHelmHistory'; export function HelmApplicationView() { const environmentId = useEnvironmentId(); + const queryClient = useQueryClient(); 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, { showResources: true, + revision: selectedRevision, }); return ( @@ -38,26 +50,61 @@ export function HelmApplicationView() {
- - {name && ( - - - +
+
+ {name && ( + +
+
+ +
+ + { + queryClient.setQueryData( + [ + environmentId, + 'helm', + 'releases', + namespace, + name, + true, + ], + updatedRelease + ); + }} + /> + +
+
+ )} + + - - - )} - - - + +
+
+ +
+
@@ -68,10 +115,16 @@ export function HelmApplicationView() { type HelmDetailsProps = { isLoading: 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) { return ; } @@ -82,16 +135,16 @@ function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) { ); } - if (!data) { + if (!release || !selectedRevision) { return ; } return ( <> - +
- + ); diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx new file mode 100644 index 000000000..638197ae0 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx @@ -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) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: () => mockUseCurrentStateAndParams(), +})); + +function getTestComponent() { + return withTestRouter(HelmRevisionItem); +} + +describe('HelmRevisionItem', () => { + it('should display correct revision details', () => { + const TestComponent = getTestComponent(); + render( + + ); + + // 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( + + ); + + 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( + + ); + + 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( + + ); + + const blocklistItem = container.querySelector('.blocklist-item'); + expect(blocklistItem).not.toHaveClass('blocklist-item--selected'); + }); +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx new file mode 100644 index 000000000..73a29107f --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx @@ -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 ( + +
+
+ + {getStatusText(item.info?.status)} + + Revision #{item.version} +
+
+ + {item.chart.metadata?.name}-{item.chart.metadata?.version} + + {item.info?.last_deployed && ( + + {localizeDate(new Date(item.info.last_deployed))} + + )} +
+
+
+ ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx new file mode 100644 index 000000000..07ed2a8b2 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx @@ -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 ( +
+
+ + +

Revisions

+
+ {history?.map((historyItem) => ( + + ))} +
+
+ ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx new file mode 100644 index 000000000..95016f080 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx @@ -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 ( + + + + View revisions + + +
+ + + View the history of this Helm application. + +
+ +
+
+ ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx index 29f9e07d8..a7e32b247 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx @@ -1,24 +1,19 @@ import { Badge } from '@/react/components/Badge'; +import { localizeDate } from '@/react/common/date-utils'; import { Alert } from '@@/Alert'; import { HelmRelease } from '../types'; +import { + DeploymentStatus, + getStatusColor, + getStatusText, +} from '../helm-status-utils'; interface Props { 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) { const isSuccess = release.info?.status === DeploymentStatus.DEPLOYED || @@ -29,9 +24,14 @@ export function HelmSummary({ release }: Props) {
- {getText(release.info?.status)} + {getStatusText(release.info?.status)}
+ {!!release.info?.description && !isSuccess && ( + + {release.info?.description} + + )}
{!!release.namespace && Namespace: {release.namespace}} {!!release.version && Revision: #{release.version}} @@ -47,12 +47,13 @@ export function HelmSummary({ release }: Props) { {release.chart.metadata.version} )} + {!!release.info?.last_deployed && ( + + Last deployed:{' '} + {localizeDate(new Date(release.info.last_deployed))} + + )}
- {!!release.info?.description && !isSuccess && ( - - {release.info?.description} - - )}
); @@ -74,38 +75,3 @@ function getAlertColor(status?: string) { 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'; - } -} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx new file mode 100644 index 000000000..109d98299 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx @@ -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( + + ); +} + +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(''); + }); +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx new file mode 100644 index 000000000..62698c40f --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx @@ -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> = [ + { label: 'View', value: 'view' }, + { + label: 'Diff with previous', + value: 'previous', + disabled: disabledPreviousOption, + }, + { + label: ( + + ), + value: 'specific', + }, + ]; + + return ( +
+ {showViewOptions && ( + + )} + {!!showUserSuppliedCheckbox && !!setIsUserSupplied && ( + setIsUserSupplied(!isUserSupplied)} + data-cy="values-details-user-supplied" + className="font-normal control-label" + bold={false} + /> + )} +
+ ); +} + +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 ( + <> + Diff with specific revision: + + + ); + + function handleSpecificRevisionChange(e: ChangeEvent) { + const inputNumber = e.target.valueAsNumber; + // handle out of range values + if (inputNumber > latestRevisionNumber) { + setCompareRevisionNumber(latestRevisionNumber); + return; + } + if (inputNumber < earliestRevisionNumber) { + setCompareRevisionNumber(earliestRevisionNumber); + return; + } + setDebouncedSetCompareRevisionNumber(inputNumber); + } +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx new file mode 100644 index 000000000..7871cbfa3 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx @@ -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 ; + } + + if (isCompareReleaseError) { + return Error loading compare values; + } + + return ( + + ); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx new file mode 100644 index 000000000..772774fe3 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx @@ -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( + + ); +} + +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); + }); +}); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx new file mode 100644 index 000000000..d02423d82 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx @@ -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 ( + + + Events reflect the latest revision only. + + } + 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 + /> + + ); +} + +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)); +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx index c95886d3f..c87ad17a3 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx @@ -1,18 +1,58 @@ +import { ReactNode } from 'react'; + import { CodeEditor } from '@@/CodeEditor'; +import { DiffViewMode } from './DiffControl'; +import { DiffViewSection } from './DiffViewSection'; +import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types'; + type Props = { 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 ( - + <> + {diffControl} + {diffViewMode === 'view' ? ( + + ) : ( + + )} + ); } diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx index 74a6135bd..be5feb0ca 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx @@ -1,9 +1,48 @@ 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 = { notes: string; + selectedRevisionNumber: SelectedRevisionNumber; + diffViewMode: DiffViewMode; + compareNotes?: string; + compareRevisionNumberFetched?: CompareRevisionNumberFetched; + isCompareReleaseLoading: boolean; + isCompareReleaseError: boolean; + diffControl: ReactNode; }; -export function NotesDetails({ notes }: Props) { - return {notes}; +export function NotesDetails({ + notes, + selectedRevisionNumber, + diffViewMode, + compareNotes, + compareRevisionNumberFetched, + isCompareReleaseLoading, + isCompareReleaseError, + diffControl, +}: Props) { + return ( + <> + {diffControl} + {diffViewMode === 'view' ? ( + {notes} + ) : ( + + )} + + ); } diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx index efa36c0c2..0aa05960c 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx @@ -1,32 +1,184 @@ import { useState } from 'react'; 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 { Badge } from '@@/Badge'; +import { Icon } from '@@/Icon'; import { HelmRelease } from '../../types'; +import { useHelmHistory } from '../queries/useHelmHistory'; import { ManifestDetails } from './ManifestDetails'; import { NotesDetails } from './NotesDetails'; import { ValuesDetails } from './ValuesDetails'; import { ResourcesTable } from './ResourcesTable/ResourcesTable'; +import { DiffControl, DiffViewMode } from './DiffControl'; +import { useHelmReleaseToCompare } from './useHelmReleaseToCompare'; +import { + filterRelatedEvents, + HelmEventsDatatable, + useHelmEventsTableState, +} from './HelmEventsDatatable'; type Props = { 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 for more details) + const [selectedCompareRevisionNumber, setSelectedCompareRevisionNumber] = + useState(NaN); + const [diffViewMode, setDiffViewMode] = useState('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 ( + + 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( release: HelmRelease, 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[] { + // 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([ { label: 'Resources', id: 'resources', children: , }, + { + label: ( + <> + Events + {eventWarningCount >= 1 && ( + + + {eventWarningCount} + + )} + + ), + id: 'events', + children: ( + + ), + }, { label: 'Values', id: 'values', @@ -34,35 +186,97 @@ function helmTabs( + } /> ), }, { label: 'Manifest', id: 'manifest', - children: , + children: ( + + ) + } + /> + ), }, !!release.info?.notes && { label: 'Notes', id: 'notes', - children: , + children: ( + + ) + } + /> + ), }, ]); } -export function ReleaseTabs({ release }: Props) { - const [tab, setTab] = useState('resources'); - // state is here so that the state isn't lost when the tab changes - const [isUserSupplied, setIsUserSupplied] = useState(true); - - return ( - - onSelect={setTab} - selectedId={tab} - type="pills" - justified - options={helmTabs(release, isUserSupplied, setIsUserSupplied)} - /> - ); +function parseValidTab(tab: string, hasNotes: boolean): Tab { + if ( + tab === 'values' || + (tab === 'notes' && hasNotes) || + tab === 'manifest' || + tab === 'resources' || + tab === 'events' + ) { + return tab; + } + return 'resources'; } diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx index a1510aef8..4442ca871 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx @@ -6,7 +6,17 @@ import { DescribeModal } from './DescribeModal'; 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; + }) =>
{value}
, +})); + vi.mock('./queries/useDescribeResource', () => ({ useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args), })); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx index 6a3b35674..8469e38fe 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx @@ -11,6 +11,7 @@ import { import { useTableState } from '@@/datatables/useTableState'; import { Widget } from '@@/Widget'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; +import { TextTip } from '@@/Tip/TextTip'; import { useHelmRelease } from '../../queries/useHelmRelease'; @@ -34,12 +35,14 @@ const settingsStore = createStore('helm-resources'); export function ResourcesTable() { const environmentId = useEnvironmentId(); 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 helmReleaseQuery = useHelmRelease(environmentId, name, namespace, { showResources: true, refetchInterval: tableState.autoRefreshRate * 1000, + revision: revisionNumber, }); const rows = useResourceRows(helmReleaseQuery.data?.info?.resources); @@ -48,11 +51,17 @@ export function ResourcesTable() { + Resources reflect the latest revision only. + + } disableSelect getRowId={(row) => row.id} data-cy="helm-resources-datatable" diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx index b9ff43fff..cded59796 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx @@ -14,5 +14,9 @@ export const status = columnHelper.accessor((row) => row.status.label, { function Cell({ row }: CellContext) { const { status } = row.original; + if (!status.label) { + return '-'; + } + return {status.label}; } diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx index b1f32ca3f..7a480660c 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx @@ -1,44 +1,74 @@ -import { Checkbox } from '@@/form-components/Checkbox'; +import { ReactNode } from 'react'; + import { CodeEditor } from '@@/CodeEditor'; import { Values } from '../../types'; +import { DiffViewMode } from './DiffControl'; +import { DiffViewSection } from './DiffViewSection'; +import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types'; + interface Props { values?: Values; 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({ values, isUserSupplied, - setIsUserSupplied, + selectedRevisionNumber, + diffViewMode, + compareValues, + compareRevisionNumberFetched, + isCompareReleaseLoading, + isCompareReleaseError, + diffControl, }: Props) { return ( -
- {/* bring in line with the code editor copy button */} -
- setIsUserSupplied(!isUserSupplied)} - data-cy="values-details-user-supplied" + <> + {diffControl} + {diffViewMode === 'view' ? ( + -
- -
+ ) : ( + + )} + ); } diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts new file mode 100644 index 000000000..0b038a0f5 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts @@ -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; diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts new file mode 100644 index 000000000..814f03a6a --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts @@ -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; +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts new file mode 100644 index 000000000..41d6ab375 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts @@ -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( + `endpoints/${environmentId}/kubernetes/helm/${name}/history`, + { + params: { namespace }, + } + ); + + return response.data; + } catch (error) { + throw parseAxiosError(error, 'Unable to retrieve helm application history'); + } +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts index 8d41e6544..cdf465c16 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts @@ -6,6 +6,15 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { HelmRelease } from '../../types'; +type Options = { + 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 */ @@ -13,39 +22,52 @@ export function useHelmRelease( environmentId: EnvironmentId, name: string, namespace: string, - options: { - select?: (data: HelmRelease) => T; - showResources?: boolean; - refetchInterval?: number; - } = {} + options: Options = {} ) { - const { select, showResources, refetchInterval } = options; + const { select, showResources, refetchInterval, revision, staleTime } = + options; return useQuery( - [environmentId, 'helm', 'releases', namespace, name, options.showResources], + [ + environmentId, + 'helm', + 'releases', + namespace, + name, + revision, + showResources, + ], () => getHelmRelease(environmentId, name, { namespace, showResources, + revision, }), { - enabled: !!environmentId && !!name && !!namespace, + enabled: !!environmentId && !!name && !!namespace && options.enabled, ...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, refetchInterval, + staleTime, } ); } +type Params = { + namespace: string; + showResources?: boolean; + revision?: number; +}; + /** * Get a specific Helm release */ async function getHelmRelease( environmentId: EnvironmentId, name: string, - params: { - namespace: string; - showResources?: boolean; - } + params: Params ) { try { const { data } = await axios.get( diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts new file mode 100644 index 000000000..733b79dea --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts @@ -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 { + try { + const { data } = await axios.get(`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'); + } +} diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts new file mode 100644 index 000000000..00e9f8131 --- /dev/null +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts @@ -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( + `endpoints/${environmentId}/kubernetes/helm`, + payload + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to update helm release'); + } +} diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx index c8def2077..5fb88d64c 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx @@ -3,8 +3,11 @@ import { useState } from 'react'; import { useCurrentUser } from '@/react/hooks/useUser'; import { Chart } from '../types'; +import { + useHelmChartList, + useHelmRepositories, +} from '../queries/useHelmChartList'; -import { useHelmChartList } from './queries/useHelmChartList'; import { HelmTemplatesList } from './HelmTemplatesList'; import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem'; @@ -18,10 +21,8 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) { const [selectedChart, setSelectedChart] = useState(null); const { user } = useCurrentUser(); - const { data: charts = [], isLoading: chartsLoading } = useHelmChartList( - user.Id - ); - + const helmReposQuery = useHelmRepositories(user.Id); + const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []); function clearHelmChart() { setSelectedChart(null); onSelectHelmChart(''); @@ -44,9 +45,9 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) { /> ) : ( )} diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx index 3c080d077..f02d83131 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx @@ -50,7 +50,7 @@ function renderComponent({ withUserProvider( withTestRouter(() => ( @@ -137,10 +137,10 @@ describe('HelmTemplatesList', () => { }); it('should show loading message when loading prop is true', async () => { - renderComponent({ loading: true }); + renderComponent({ loading: true, charts: [] }); // Check for loading message - expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.getByText('Loading helm charts...')).toBeInTheDocument(); expect( screen.getByText('Initial download of Helm charts can take a few minutes') ).toBeInTheDocument(); diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx index 3d9034f4c..c41b0acbb 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx @@ -5,13 +5,14 @@ import { Link } from '@/react/components/Link'; import { InsightsBox } from '@@/InsightsBox'; import { SearchBar } from '@@/datatables/SearchBar'; +import { InlineLoader } from '@@/InlineLoader'; import { Chart } from '../types'; import { HelmTemplatesListItem } from './HelmTemplatesListItem'; interface Props { - loading: boolean; + isLoading: boolean; charts?: Chart[]; selectAction: (chart: Chart) => void; } @@ -70,7 +71,7 @@ function getFilteredCharts( } export function HelmTemplatesList({ - loading, + isLoading, charts = [], selectAction, }: Props) { @@ -159,16 +160,20 @@ export function HelmTemplatesList({
No Helm charts found
)} - {loading && ( -
- Loading... -
- Initial download of Helm charts can take a few minutes -
+ {isLoading && ( +
+ + Loading helm charts... + + {charts.length === 0 && ( +
+ Initial download of Helm charts can take a few minutes +
+ )}
)} - {!loading && charts.length === 0 && ( + {!isLoading && charts.length === 0 && (
No helm charts available.
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx index 655b1d928..215838ff8 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx @@ -106,10 +106,7 @@ describe('HelmTemplatesSelectedItem', () => { renderComponent(); const user = userEvent.setup(); - // First show the editor - await user.click(await screen.findByText('Custom values')); - - // Verify editor is visible + // Verify editor is visible by default expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument(); // Now hide the editor diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx index 088c0c69a..baffb9655 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx @@ -143,7 +143,12 @@ export function HelmTemplatesSelectedItem({ {({ values, setFieldValue }) => (
- + {loadingValues && (
Loading values.yaml... @@ -156,7 +161,7 @@ export function HelmTemplatesSelectedItem({ onChange={(value) => setFieldValue('values', value)} type="yaml" 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 in the{' '} diff --git a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts deleted file mode 100644 index 60791fe59..000000000 --- a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts +++ /dev/null @@ -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 { - try { - const response = await axios.get( - `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 { - try { - // Construct the URL with required repo parameter - const response = await axios.get('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 { - 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'), - }); -} diff --git a/app/react/kubernetes/helm/helm-status-utils.ts b/app/react/kubernetes/helm/helm-status-utils.ts new file mode 100644 index 000000000..60fd83e86 --- /dev/null +++ b/app/react/kubernetes/helm/helm-status-utils.ts @@ -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'; + } +} diff --git a/app/react/kubernetes/helm/queries/useHelmChartList.ts b/app/react/kubernetes/helm/queries/useHelmChartList.ts new file mode 100644 index 000000000..068588c42 --- /dev/null +++ b/app/react/kubernetes/helm/queries/useHelmChartList.ts @@ -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( + `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 { + try { + // Construct the URL with required repo parameter + const response = await axios.get('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), + }; +} diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts index 49445fa30..94f8a3a8e 100644 --- a/app/react/kubernetes/helm/types.ts +++ b/app/react/kubernetes/helm/types.ts @@ -16,6 +16,7 @@ export interface GenericResource { metadata: { name: string; namespace?: string; + uid?: string; }; status: ResourceStatus; } @@ -29,6 +30,7 @@ export interface HelmRelease { notes?: string; description?: string; resources?: GenericResource[]; + last_deployed: string; }; /** The chart that was released */ chart: HelmChart; @@ -104,10 +106,10 @@ export interface HelmChartsResponse { generated: string; } -export type InstallChartPayload = { +export interface InstallChartPayload { Name: string; Repo: string; Chart: string; Values: string; Namespace: string; -}; +} diff --git a/app/react/kubernetes/queries/useEvents.ts b/app/react/kubernetes/queries/useEvents.ts index 87d38eb94..f25c255db 100644 --- a/app/react/kubernetes/queries/useEvents.ts +++ b/app/react/kubernetes/queries/useEvents.ts @@ -1,4 +1,4 @@ -import { EventList } from 'kubernetes-types/core/v1'; +import { EventList, Event } from 'kubernetes-types/core/v1'; import { useQuery } from '@tanstack/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; @@ -41,7 +41,7 @@ const queryKeys = { async function getEvents( environmentId: EnvironmentId, options?: RequestOptions -) { +): Promise { const { namespace, params } = options ?? {}; try { const { data } = await axios.get( @@ -56,15 +56,16 @@ async function getEvents( } } -type QueryOptions = { +type QueryOptions = { queryOptions?: { autoRefreshRate?: number; + select?: (data: Event[]) => T; }; } & RequestOptions; -export function useEvents( +export function useEvents( environmentId: EnvironmentId, - options?: QueryOptions + options?: QueryOptions ) { const { queryOptions, params, namespace } = options ?? {}; return useQuery( @@ -75,6 +76,7 @@ export function useEvents( refetchInterval() { return queryOptions?.autoRefreshRate ?? false; }, + select: queryOptions?.select, } ); } @@ -83,11 +85,13 @@ export function useEventWarningsCount( environmentId: EnvironmentId, namespace?: string ) { - const resourceEventsQuery = useEvents(environmentId, { + const resourceEventsQuery = useEvents(environmentId, { namespace, + queryOptions: { + select: (data) => data.filter((e) => e.type === 'Warning').length, + }, }); - const events = resourceEventsQuery.data || []; - return events.filter((e) => e.type === 'Warning').length; + return resourceEventsQuery.data || 0; } function buildUrl(environmentId: EnvironmentId, namespace?: string) { diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx index 727c4f034..7ed2f4651 100644 --- a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx @@ -59,6 +59,7 @@ export function HelmRepositoryDatatable() { return ( String(row.Id)} dataset={helmRepos} description={} diff --git a/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx index 7d7729c64..c14b35e23 100644 --- a/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx +++ b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx @@ -101,7 +101,7 @@ export function InnerForm({ value={values.FileContent} onChange={handleChangeFileContent} type="yaml" - placeholder={texts.editor.placeholder} + textTip={texts.editor.placeholder} error={errors.FileContent} > {texts.editor.description} diff --git a/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx index 9a78821f0..d96f9fe1f 100644 --- a/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx +++ b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx @@ -93,7 +93,7 @@ export function InnerForm({ value={gitFileContent || values.FileContent} onChange={handleChangeFileContent} type="yaml" - placeholder={ + textTip={ gitFileContent ? 'Preview of the file from git repository' : texts.editor.placeholder diff --git a/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx index fb0f214cc..fb9ebb6bc 100644 --- a/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx +++ b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx @@ -121,7 +121,7 @@ export function DeployForm({ }} type="yaml" error={errors.fileContent} - placeholder="Define or paste the content of your docker compose file here" + textTip="Define or paste the content of your docker compose file here" readonly={isGit} data-cy="custom-template-creation-editor" > diff --git a/app/setup-tests/mock-codemirror.tsx b/app/setup-tests/mock-codemirror.tsx index a6ffc8eda..5c96025b8 100644 --- a/app/setup-tests/mock-codemirror.tsx +++ b/app/setup-tests/mock-codemirror.tsx @@ -7,6 +7,21 @@ export function mockCodeMirror() { of: () => ({}), }, })); + + vi.mock('react-codemirror-merge', () => { + const components = { + MergeView: () =>
, + Original: () =>
, + Modified: () =>
, + }; + + return { + __esModule: true, + default: components, + ...components, + }; + }); + vi.mock('yaml-schema', () => ({ yamlSchema: () => [], validation: () => ({ diff --git a/app/setup-tests/mock-localizeDate.ts b/app/setup-tests/mock-localizeDate.ts new file mode 100644 index 000000000..5463006b8 --- /dev/null +++ b/app/setup-tests/mock-localizeDate.ts @@ -0,0 +1,18 @@ +export function mockLocalizeDate() { + // Mock localizeDate to always use en-US and UTC + vi.mock('@/react/common/date-utils', () => ({ + localizeDate: (date: Date) => + date + .toLocaleString('en-US', { + timeZone: 'UTC', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) + .replace('am', 'AM') + .replace('pm', 'PM'), + })); +} diff --git a/package.json b/package.json index ff81a1bb0..7d1c72b83 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@lezer/highlight": "^1.1.3", "@nxmix/tokenize-ansi": "^3.0.0", "@open-amt-cloud-toolkit/ui-toolkit-react": "2.0.0", + "@radix-ui/react-dialog": "^1.1.1", "@reach/combobox": "^0.18.0", "@reach/dialog": "^0.17.0", "@reach/menu-button": "^0.16.1", @@ -85,6 +86,7 @@ "c8": "^9.1.0", "chardet": "^1.4.0", "chart.js": "^2.7.0", + "class-variance-authority": "^0.7.0", "clsx": "^1.1.1", "codemirror": "^6.0.1", "codemirror-json-schema": "^0.8.0", @@ -116,6 +118,7 @@ "rc-slider": "^10.0.0", "react": "^17.0.2", "react-calendar": "^4.8.0", + "react-codemirror-merge": "^4.23.11", "react-datetime-picker": "^5.6.0", "react-dom": "^17.0.2", "react-i18next": "^11.12.0", @@ -125,6 +128,7 @@ "sanitize-html": "^2.8.1", "spinkit": "^2.0.1", "strip-ansi": "^6.0.0", + "tailwindcss-animate": "^1.0.7", "tippy.js": "^6.3.7", "toastr": "^2.1.4", "ts-xor": "^1.1.0", diff --git a/pkg/libhelm/options/install_options.go b/pkg/libhelm/options/install_options.go index 943f28041..0028c6c0c 100644 --- a/pkg/libhelm/options/install_options.go +++ b/pkg/libhelm/options/install_options.go @@ -5,6 +5,7 @@ import "time" type InstallOptions struct { Name string Chart string + Version string Namespace string Repo string Wait bool diff --git a/pkg/libhelm/sdk/common.go b/pkg/libhelm/sdk/common.go index b1d218cae..2a51c7f8b 100644 --- a/pkg/libhelm/sdk/common.go +++ b/pkg/libhelm/sdk/common.go @@ -15,9 +15,10 @@ import ( // loadAndValidateChartWithPathOptions locates and loads the chart, and validates it. // it also checks for chart dependencies and updates them if necessary. // it returns the chart information. -func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPathOptions *action.ChartPathOptions, chartName, repoURL string, dependencyUpdate bool, operation string) (*chart.Chart, error) { +func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPathOptions *action.ChartPathOptions, chartName, version string, repoURL string, dependencyUpdate bool, operation string) (*chart.Chart, error) { // Locate and load the chart chartPathOptions.RepoURL = repoURL + chartPathOptions.Version = version chartPath, err := chartPathOptions.LocateChart(chartName, hspm.settings) if err != nil { log.Error(). diff --git a/pkg/libhelm/sdk/get.go b/pkg/libhelm/sdk/get.go index 51a5c3bce..c15b65715 100644 --- a/pkg/libhelm/sdk/get.go +++ b/pkg/libhelm/sdk/get.go @@ -3,6 +3,7 @@ package sdk import ( "github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/release" + "github.com/portainer/portainer/pkg/libhelm/time" "github.com/rs/zerolog/log" "helm.sh/helm/v3/pkg/action" sdkrelease "helm.sh/helm/v3/pkg/release" @@ -82,10 +83,11 @@ func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Rel Namespace: sdkRelease.Namespace, Version: sdkRelease.Version, Info: &release.Info{ - Status: release.Status(sdkRelease.Info.Status), - Notes: sdkRelease.Info.Notes, - Resources: resources, - Description: sdkRelease.Info.Description, + Status: release.Status(sdkRelease.Info.Status), + Notes: sdkRelease.Info.Notes, + Resources: resources, + Description: sdkRelease.Info.Description, + LastDeployed: time.Time(sdkRelease.Info.LastDeployed), }, Manifest: sdkRelease.Manifest, Chart: release.Chart{ diff --git a/pkg/libhelm/sdk/history.go b/pkg/libhelm/sdk/history.go index ba6fa4d5d..109ab68aa 100644 --- a/pkg/libhelm/sdk/history.go +++ b/pkg/libhelm/sdk/history.go @@ -1,6 +1,8 @@ package sdk import ( + "sort" + "github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/release" "github.com/portainer/portainer/pkg/libhelm/time" @@ -44,6 +46,11 @@ func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOpti result = append(result, convertHistory(r)) } + // sort the result by version (latest first) + sort.Slice(result, func(i, j int) bool { + return result[i].Version > result[j].Version + }) + return result, nil } diff --git a/pkg/libhelm/sdk/install.go b/pkg/libhelm/sdk/install.go index 29f578806..ca904dcca 100644 --- a/pkg/libhelm/sdk/install.go +++ b/pkg/libhelm/sdk/install.go @@ -60,7 +60,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation") } - chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Repo, installClient.DependencyUpdate, "release installation") + chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Version, installOpts.Repo, installClient.DependencyUpdate, "release installation") if err != nil { log.Error(). Str("context", "HelmClient"). @@ -109,7 +109,6 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( // and return the install client. func initInstallClient(actionConfig *action.Configuration, installOpts options.InstallOptions) (*action.Install, error) { installClient := action.NewInstall(actionConfig) - installClient.CreateNamespace = true installClient.DependencyUpdate = true installClient.ReleaseName = installOpts.Name installClient.ChartPathOptions.RepoURL = installOpts.Repo diff --git a/pkg/libhelm/sdk/upgrade.go b/pkg/libhelm/sdk/upgrade.go index cc284cf31..cf5124370 100644 --- a/pkg/libhelm/sdk/upgrade.go +++ b/pkg/libhelm/sdk/upgrade.go @@ -84,7 +84,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) ( return nil, errors.Wrap(err, "failed to get Helm values from file for helm release upgrade") } - chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Chart, upgradeOpts.Repo, upgradeClient.DependencyUpdate, "release upgrade") + chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Chart, upgradeOpts.Version, upgradeOpts.Repo, upgradeClient.DependencyUpdate, "release upgrade") if err != nil { log.Error(). Str("context", "HelmClient"). diff --git a/pkg/libhelm/sdk/values.go b/pkg/libhelm/sdk/values.go index 8818f998f..8dc6325a3 100644 --- a/pkg/libhelm/sdk/values.go +++ b/pkg/libhelm/sdk/values.go @@ -62,8 +62,7 @@ func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (releas return release.Values{}, err } - // Create client for user supplied values - userValuesClient := action.NewGetValues(actionConfig) + userValuesClient := hspm.initValuesClient(actionConfig, getOpts) userSuppliedValues, err := userValuesClient.Run(getOpts.Name) if err != nil { log.Error(). @@ -116,3 +115,11 @@ func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (releas ComputedValues: computedValuesString, }, nil } + +func (hspm *HelmSDKPackageManager) initValuesClient(actionConfig *action.Configuration, getOpts options.GetOptions) *action.GetValues { + valuesClient := action.NewGetValues(actionConfig) + if getOpts.Revision > 0 { + valuesClient.Version = getOpts.Revision + } + return valuesClient +} diff --git a/tailwind.config.js b/tailwind.config.js index 490546a7b..69ed9c0dd 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -38,5 +38,6 @@ module.exports = { plugin(function ({ addVariant }) { addVariant('progress-filled', ['&::-webkit-progress-value', '&::-moz-progress-bar']); }), + require('tailwindcss-animate'), ], }; diff --git a/yarn.lock b/yarn.lock index 4f1b90b95..3bdc96e8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3235,6 +3235,17 @@ "@codemirror/view" "^6.35.0" crelt "^1.0.5" +"@codemirror/merge@^6.1.2": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@codemirror/merge/-/merge-6.10.0.tgz#0a1be2a4561594f6adc1b1adcf6e7ac23d767b85" + integrity sha512-Omn0gU6MM5cKQGqgKoIhFjUqCNWH/nukCMLXzu/1jOdtiHsxAu3GdENBf1QYkoIC3FSgkF7X/ClOqYeUATQ4Sw== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/highlight" "^1.0.0" + style-mod "^4.1.0" + "@codemirror/search@^6.0.0", "@codemirror/search@^6.2.3": version "6.5.8" resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.8.tgz#b59b3659b46184cc75d6108d7c050a4ca344c3a0" @@ -4132,6 +4143,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/primitive@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65" + integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA== + "@radix-ui/react-arrow@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d" @@ -4158,6 +4174,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-compose-refs@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30" + integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== + "@radix-ui/react-context@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" @@ -4165,6 +4186,31 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-context@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36" + integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA== + +"@radix-ui/react-dialog@^1.1.1": + version "1.1.11" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.11.tgz#1144609cbc9f8b36bcc288beb880f6b72cbd85ee" + integrity sha512-yI7S1ipkP5/+99qhSI6nthfo/tR6bL6Zgxi/+1UO6qPa6UeM6nlafWcQ65vB4rU2XjgjMfMhI3k9Y5MztA62VQ== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-dismissable-layer" "1.1.7" + "@radix-ui/react-focus-guards" "1.1.2" + "@radix-ui/react-focus-scope" "1.1.4" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-portal" "1.1.6" + "@radix-ui/react-presence" "1.1.4" + "@radix-ui/react-primitive" "2.1.0" + "@radix-ui/react-slot" "1.2.0" + "@radix-ui/react-use-controllable-state" "1.2.2" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + "@radix-ui/react-direction@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" @@ -4184,6 +4230,17 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-escape-keydown" "1.0.3" +"@radix-ui/react-dismissable-layer@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.7.tgz#80b5c23a0d29cfe56850399210c603376c27091f" + integrity sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.1.0" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-escape-keydown" "1.1.1" + "@radix-ui/react-focus-guards@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad" @@ -4191,6 +4248,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-focus-guards@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz#4ec9a7e50925f7fb661394460045b46212a33bed" + integrity sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA== + "@radix-ui/react-focus-scope@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.3.tgz#9c2e8d4ed1189a1d419ee61edd5c1828726472f9" @@ -4201,6 +4263,15 @@ "@radix-ui/react-primitive" "1.0.3" "@radix-ui/react-use-callback-ref" "1.0.1" +"@radix-ui/react-focus-scope@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.4.tgz#dbe9ed31b36ff9aadadf4b59aa733a4e91799d15" + integrity sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.1.0" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-id@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0" @@ -4209,6 +4280,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-id@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7" + integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-popper@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.2.tgz#4c0b96fcd188dc1f334e02dba2d538973ad842e9" @@ -4234,6 +4312,22 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-portal@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.6.tgz#4202e1bb34afdac612e4e982eca8efd36cbc611f" + integrity sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw== + dependencies: + "@radix-ui/react-primitive" "2.1.0" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-presence@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.4.tgz#253ac0ad4946c5b4a9c66878335f5cf07c967ced" + integrity sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-primitive@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0" @@ -4242,6 +4336,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-slot" "1.0.2" +"@radix-ui/react-primitive@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz#9233e17a22d0010195086f8b5eb1808ebbca8437" + integrity sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw== + dependencies: + "@radix-ui/react-slot" "1.2.0" + "@radix-ui/react-roving-focus@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974" @@ -4302,6 +4403,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.1" +"@radix-ui/react-slot@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba" + integrity sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-toggle-group@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz#f5b5c8c477831b013bec3580c55e20a68179d6ec" @@ -4347,6 +4455,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-use-callback-ref@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40" + integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg== + "@radix-ui/react-use-controllable-state@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286" @@ -4355,6 +4468,21 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-callback-ref" "1.0.1" +"@radix-ui/react-use-controllable-state@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190" + integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg== + dependencies: + "@radix-ui/react-use-effect-event" "0.0.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-use-effect-event@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907" + integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-escape-keydown@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755" @@ -4363,6 +4491,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-callback-ref" "1.0.1" +"@radix-ui/react-use-escape-keydown@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29" + integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-layout-effect@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399" @@ -4370,6 +4505,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-use-layout-effect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" + integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== + "@radix-ui/react-use-previous@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz#b595c087b07317a4f143696c6a01de43b0d0ec66" @@ -6711,6 +6851,19 @@ classnames "^2.3.1" prop-types "^15.6.1" +"@uiw/codemirror-extensions-basic-setup@4.23.11": + version "4.23.11" + resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.11.tgz#80a1d287c2231c00b0bed595e65e1761b813f5d1" + integrity sha512-U31s5LEqEKFU4SPz1tldfrPohKddxC02z1kTnWe50k+K0CYK+PtISD5ufH/PVC0EgGL+c0TydTx72nRMwaeC4A== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/commands" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/search" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + "@uiw/codemirror-extensions-basic-setup@4.23.7": version "4.23.7" resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.7.tgz#8fce5d6190a755c889805d2edc5b85d7f29cd322" @@ -6733,6 +6886,18 @@ "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.0.0" +"@uiw/react-codemirror@4.23.11": + version "4.23.11" + resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.23.11.tgz#a0e5091ab5f7bc4514a1ccee53280468a169f470" + integrity sha512-oMUXl/yu/a8qKy7w7q769kH2TPlvPln6IpvkjXQX90ziD8GHRFLDz+Ij51D2RRwB3glrkPnhCN9YC+PDlnmhqA== + dependencies: + "@babel/runtime" "^7.18.6" + "@codemirror/commands" "^6.1.0" + "@codemirror/state" "^6.1.1" + "@codemirror/theme-one-dark" "^6.0.0" + "@uiw/codemirror-extensions-basic-setup" "4.23.11" + codemirror "^6.0.0" + "@uiw/react-codemirror@^4.19.5": version "4.23.7" resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.23.7.tgz#b7fe2085936c593514f5e238865989bfef65e504" @@ -7352,6 +7517,13 @@ aria-hidden@^1.1.1: dependencies: tslib "^2.0.0" +aria-hidden@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522" + integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A== + dependencies: + tslib "^2.0.0" + aria-query@5.1.3, aria-query@^5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" @@ -8321,6 +8493,13 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.0.tgz#b4ed1fb6818dea4803a55c623041f9165d2066b2" integrity sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw== +class-variance-authority@^0.7.0: + version "0.7.1" + resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787" + integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg== + dependencies: + clsx "^2.1.1" + classnames@^2.2.5, classnames@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" @@ -8461,6 +8640,11 @@ clsx@^2.0.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb" integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + codemirror-json-schema@^0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/codemirror-json-schema/-/codemirror-json-schema-0.8.0.tgz#3e8b39a038148645c23a006e505becce68397602" @@ -13152,7 +13336,7 @@ markdown-to-jsx@^7.7.4: version "7.7.4" resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.7.4.tgz#507d17c15af72ddf970fca84a95f0243244fcfa9" integrity sha512-1bSfXyBKi+EYS3YY+e0Csuxf8oZ3decdfhOav/Z7Wrk89tjudyL5FOmwZQUoy0/qVXGUl+6Q3s2SWtpDEWITfQ== - + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -15109,6 +15293,15 @@ react-clock@^4.5.0: get-user-locale "^2.2.1" prop-types "^15.6.0" +react-codemirror-merge@^4.23.11: + version "4.23.11" + resolved "https://registry.yarnpkg.com/react-codemirror-merge/-/react-codemirror-merge-4.23.11.tgz#84d23df93520057efcbfdd7755dc40828eafa182" + integrity sha512-QimYLcFNKInmJhGUsIkQyTc893RGndj79Na+r4QOCfJk8TVA+ejTdc6ofB5tP5osvoxh6OIBBmeIj1vB6co/Aw== + dependencies: + "@babel/runtime" "^7.18.6" + "@codemirror/merge" "^6.1.2" + "@uiw/react-codemirror" "4.23.11" + react-colorful@^5.1.2: version "5.6.1" resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" @@ -15280,6 +15473,14 @@ react-remove-scroll-bar@^2.3.3: react-style-singleton "^2.2.1" tslib "^2.0.0" +react-remove-scroll-bar@^2.3.7: + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== + dependencies: + react-style-singleton "^2.2.2" + tslib "^2.0.0" + react-remove-scroll@2.5.5: version "2.5.5" resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77" @@ -15302,6 +15503,17 @@ react-remove-scroll@^2.4.3: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-remove-scroll@^2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz#df02cde56d5f2731e058531f8ffd7f9adec91ac2" + integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + react-select@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.2.1.tgz#416c25c6b79b94687702374e019c4f2ed9d159d6" @@ -15333,6 +15545,14 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" +react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== + dependencies: + get-nonce "^1.0.0" + tslib "^2.0.0" + react-time-picker@^6.5.0: version "6.6.0" resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-6.6.0.tgz#5c5264d053dff22cbed9ad0ba927b1ea786c3a49" @@ -16901,6 +17121,11 @@ tabbable@^5.3.3: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== +tailwindcss-animate@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4" + integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA== + tailwindcss@3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf" @@ -17676,6 +17901,13 @@ use-callback-ref@^1.3.0: dependencies: tslib "^2.0.0" +use-callback-ref@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" + integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== + dependencies: + tslib "^2.0.0" + use-resize-observer@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c" @@ -17691,6 +17923,14 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" +use-sidecar@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"