From cd5f342da09090d5fdfc9f2aa75b52a993e1930e Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 9 May 2024 18:02:20 +0300 Subject: [PATCH] refactor(edge/stacks): migrate edit view to react [EE-2222] (#11648) --- app/edge/__module.js | 5 +- app/edge/react/components/index.ts | 30 +- app/edge/react/views/edge-stacks.ts | 10 + app/edge/react/views/index.ts | 5 - .../editEdgeStackView/editEdgeStackView.html | 39 -- .../editEdgeStackViewController.js | 117 ----- .../edge-stacks/editEdgeStackView/index.js | 8 - app/edge/views/edge-stacks/index.js | 3 - app/portainer/react/components/index.ts | 2 + app/react/components/CodeEditor.tsx | 65 ++- app/react/components/NavTabs/NavTabs.tsx | 23 +- .../StackVersionSelector.tsx | 58 +++ .../components/StackVersionSelector/index.ts | 1 + app/react/components/WebEditorForm.tsx | 8 +- app/react/edge/edge-stacks/ItemView/.keep | 0 .../EditEdgeStackForm/ComposeForm.tsx | 6 + .../EditEdgeStackForm/EditEdgeStackForm.tsx | 267 +---------- .../EditEdgeStackForm/KubernetesForm.tsx | 6 + .../EditEdgeStackForm/NonGitStackForm.tsx | 430 ++++++++++++++++++ .../ItemView/EditEdgeStackForm/types.ts | 4 +- .../useAllowKubeToSelectCompose.ts | 23 + .../useStaggerUpdateStatus.ts | 35 ++ .../useUpdateEdgeStackMutation.ts | 56 +++ .../EnvironmentsDatatable.tsx | 1 + .../edge/edge-stacks/ItemView/ItemView.tsx | 74 +++ .../edge/edge-stacks/queries/query-keys.ts | 2 + .../edge-stacks/queries/useEdgeStackFile.ts | 44 ++ app/react/edge/edge-stacks/types.ts | 7 +- app/react/hooks/useIdParam.ts | 13 + app/react/hooks/useParamState.ts | 2 +- .../custom-templates/EditView/InnerForm.tsx | 2 +- 31 files changed, 847 insertions(+), 499 deletions(-) delete mode 100644 app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html delete mode 100644 app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js delete mode 100644 app/edge/views/edge-stacks/editEdgeStackView/index.js delete mode 100644 app/edge/views/edge-stacks/index.js create mode 100644 app/react/components/StackVersionSelector/StackVersionSelector.tsx create mode 100644 app/react/components/StackVersionSelector/index.ts delete mode 100644 app/react/edge/edge-stacks/ItemView/.keep create mode 100644 app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx create mode 100644 app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useAllowKubeToSelectCompose.ts create mode 100644 app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useStaggerUpdateStatus.ts create mode 100644 app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useUpdateEdgeStackMutation.ts create mode 100644 app/react/edge/edge-stacks/ItemView/ItemView.tsx create mode 100644 app/react/edge/edge-stacks/queries/useEdgeStackFile.ts create mode 100644 app/react/hooks/useIdParam.ts diff --git a/app/edge/__module.js b/app/edge/__module.js index db971c8c8..32f606220 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -1,11 +1,10 @@ import angular from 'angular'; import { AccessHeaders } from '@/portainer/authorization-guard'; -import edgeStackModule from './views/edge-stacks'; import { reactModule } from './react'; angular - .module('portainer.edge', [edgeStackModule, reactModule]) + .module('portainer.edge', [reactModule]) .config(function config($stateRegistryProvider) { const edge = { @@ -82,7 +81,7 @@ angular url: '/:stackId?tab&status', views: { 'content@': { - component: 'editEdgeStackView', + component: 'edgeStacksItemView', }, }, params: { diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 22fd69132..a754170cd 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -6,22 +6,15 @@ import { withReactQuery } from '@/react-tools/withReactQuery'; import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField'; import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm'; -import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector'; -import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm'; -import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; -import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable'; import { edgeJobsModule } from './edge-jobs'; const ngModule = angular .module('portainer.edge.react.components', [edgeJobsModule]) - .component( - 'edgeStackEnvironmentsDatatable', - r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), []) - ) + .component( 'edgeGroupsSelector', r2a(withUIRouter(withReactQuery(EdgeGroupsSelector)), [ @@ -64,27 +57,6 @@ const ngModule = angular 'fieldSettings', ]) ) - .component( - 'edgeStackDeploymentTypeSelector', - r2a(withReactQuery(EdgeStackDeploymentTypeSelector), [ - 'value', - 'onChange', - 'hasDockerEndpoint', - 'hasKubeEndpoint', - 'allowKubeToSelectCompose', - ]) - ) - .component( - 'editEdgeStackForm', - r2a(withUIRouter(withReactQuery(withCurrentUser(EditEdgeStackForm))), [ - 'edgeStack', - 'fileContent', - 'isSubmitting', - 'onEditorChange', - 'onSubmit', - 'allowKubeToSelectCompose', - ]) - ) .component( 'edgeGroupAssociationTable', r2a(withReactQuery(EdgeGroupAssociationTable), [ diff --git a/app/edge/react/views/edge-stacks.ts b/app/edge/react/views/edge-stacks.ts index 7b074ef9f..41cbeed7b 100644 --- a/app/edge/react/views/edge-stacks.ts +++ b/app/edge/react/views/edge-stacks.ts @@ -4,10 +4,20 @@ import { r2a } from '@/react-tools/react2angular'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { CreateView } from '@/react/edge/edge-stacks/CreateView/CreateView'; +import { ItemView } from '@/react/edge/edge-stacks/ItemView/ItemView'; +import { ListView } from '@/react/edge/edge-stacks/ListView'; export const stacksModule = angular .module('portainer.edge.react.views.stacks', []) .component( 'edgeStacksCreateView', r2a(withCurrentUser(withUIRouter(CreateView)), []) + ) + .component( + 'edgeStacksItemView', + r2a(withCurrentUser(withUIRouter(ItemView)), []) + ) + .component( + 'edgeStacksView', + r2a(withUIRouter(withCurrentUser(ListView)), []) ).name; diff --git a/app/edge/react/views/index.ts b/app/edge/react/views/index.ts index 3be8c997e..e4f010382 100644 --- a/app/edge/react/views/index.ts +++ b/app/edge/react/views/index.ts @@ -5,7 +5,6 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { WaitingRoomView } from '@/react/edge/edge-devices/WaitingRoomView'; -import { ListView as EdgeStacksListView } from '@/react/edge/edge-stacks/ListView'; import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView'; import { templatesModule } from './templates'; @@ -22,10 +21,6 @@ export const viewsModule = angular 'waitingRoomView', r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), []) ) - .component( - 'edgeStacksView', - r2a(withUIRouter(withCurrentUser(EdgeStacksListView)), []) - ) .component( 'edgeGroupsView', r2a(withUIRouter(withCurrentUser(EdgeGroupsListView)), []) diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html deleted file mode 100644 index 4bb6abb8b..000000000 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html +++ /dev/null @@ -1,39 +0,0 @@ - - -
-
- - - - - - Stack - - -
- -
-
- - - Environments - - -
- -
-
-
-
-
-
-
diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js deleted file mode 100644 index 02351db9d..000000000 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js +++ /dev/null @@ -1,117 +0,0 @@ -import { confirmWebEditorDiscard } from '@@/modals/confirm'; -import { EnvironmentType } from '@/react/portainer/environments/types'; -import { createWebhookId } from '@/portainer/helpers/webhookHelper'; - -export class EditEdgeStackViewController { - /* @ngInject */ - constructor($async, $state, $window, EdgeGroupService, EdgeStackService, Notifications) { - this.$async = $async; - this.$state = $state; - this.$window = $window; - this.EdgeGroupService = EdgeGroupService; - this.EdgeStackService = EdgeStackService; - this.Notifications = Notifications; - - this.stack = null; - this.edgeGroups = null; - - this.state = { - actionInProgress: false, - activeTab: 0, - isStackDeployed: false, - }; - - this.formValues = { - content: null, - }; - - this.deployStack = this.deployStack.bind(this); - this.deployStackAsync = this.deployStackAsync.bind(this); - this.onEditorChange = this.onEditorChange.bind(this); - this.isEditorDirty = this.isEditorDirty.bind(this); - } - - async $onInit() { - return this.$async(async () => { - const { stackId, tab } = this.$state.params; - this.state.activeTab = tab ? parseInt(tab, 10) : 0; - try { - const [edgeGroups, model, file] = await Promise.all([ - this.EdgeGroupService.groups(), - this.EdgeStackService.stack(stackId), - this.EdgeStackService.stackFile(stackId).catch(() => ''), - ]); - - this.edgeGroups = edgeGroups; - this.stack = model; - this.originalFileContent = file; - this.formValues = { - content: file, - }; - - const stackEdgeGroups = model.EdgeGroups.map((id) => this.edgeGroups.find((e) => e.Id === id)); - const endpointTypes = stackEdgeGroups.flatMap((group) => group.EndpointTypes); - const initiallyContainsKubeEnv = endpointTypes.includes(EnvironmentType.EdgeAgentOnKubernetes); - const isComposeStack = this.stack.DeploymentType === 0; - - this.allowKubeToSelectCompose = initiallyContainsKubeEnv && isComposeStack; - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve stack data'); - } - - this.oldFileContent = this.formValues.StackFileContent; - - this.$window.onbeforeunload = () => { - if (this.isEditorDirty()) { - return ''; - } - }; - }); - } - - $onDestroy() { - this.$window.onbeforeunload = undefined; - } - - async uiCanExit() { - if (this.isEditorDirty()) { - return confirmWebEditorDiscard(); - } - } - - onEditorChange(content) { - this.formValues.content = content; - } - - isEditorDirty() { - return !this.state.isStackDeployed && this.formValues.content.replace(/(\r\n|\n|\r)/gm, '') !== this.originalFileContent.replace(/(\r\n|\n|\r)/gm, ''); - } - - deployStack(values) { - return this.deployStackAsync(values); - } - - async deployStackAsync(values) { - this.state.actionInProgress = true; - try { - const updateVersion = !!(this.originalFileContent != values.content || values.useManifestNamespaces !== this.stack.UseManifestNamespaces); - - await this.EdgeStackService.updateStack(this.stack.Id, { - stackFileContent: values.content, - edgeGroups: values.edgeGroups, - deploymentType: values.deploymentType, - updateVersion, - retryDeploy: values.retryDeploy, - webhook: values.webhookEnabled ? this.stack.Webhook || createWebhookId() : '', - envVars: values.envVars, - }); - this.Notifications.success('Success', 'Stack successfully deployed'); - this.state.isStackDeployed = true; - this.$state.go('edge.stacks'); - } catch (err) { - this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); - } finally { - this.state.actionInProgress = false; - } - } -} diff --git a/app/edge/views/edge-stacks/editEdgeStackView/index.js b/app/edge/views/edge-stacks/editEdgeStackView/index.js deleted file mode 100644 index f81791347..000000000 --- a/app/edge/views/edge-stacks/editEdgeStackView/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import angular from 'angular'; - -import { EditEdgeStackViewController } from './editEdgeStackViewController'; - -angular.module('portainer.edge').component('editEdgeStackView', { - templateUrl: './editEdgeStackView.html', - controller: EditEdgeStackViewController, -}); diff --git a/app/edge/views/edge-stacks/index.js b/app/edge/views/edge-stacks/index.js deleted file mode 100644 index e44a9bb23..000000000 --- a/app/edge/views/edge-stacks/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import angular from 'angular'; - -export default angular.module('portainer.edge.stacks', []).name; diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 0ba694cbe..b28d89fc4 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -229,6 +229,8 @@ export const ngModule = angular 'value', 'height', 'data-cy', + 'versions', + 'onVersionChange', ]) ) .component( diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx index acdc57460..3144a94b0 100644 --- a/app/react/components/CodeEditor.tsx +++ b/app/react/components/CodeEditor.tsx @@ -3,7 +3,7 @@ import { StreamLanguage, LanguageSupport } 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 { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { createTheme } from '@uiw/codemirror-themes'; import { tags as highlightTags } from '@lezer/highlight'; @@ -13,6 +13,7 @@ import { CopyButton } from '@@/buttons/CopyButton'; import styles from './CodeEditor.module.css'; import { TextTip } from './Tip/TextTip'; +import { StackVersionSelector } from './StackVersionSelector'; interface Props extends AutomationTestingProps { id: string; @@ -24,6 +25,8 @@ interface Props extends AutomationTestingProps { onChange: (value: string) => void; value: string; height?: string; + versions?: number[]; + onVersionChange?: (version: number) => void; } const theme = createTheme({ @@ -65,12 +68,16 @@ export function CodeEditor({ placeholder, readonly, value, + versions, + onVersionChange, height = '500px', yaml: isYaml, dockerFile: isDockerFile, shell: isShell, 'data-cy': dataCy, }: Props) { + const [isRollback, setIsRollback] = useState(false); + const extensions = useMemo(() => { const extensions = []; if (isYaml) { @@ -85,30 +92,56 @@ export function CodeEditor({ return extensions; }, [isYaml, isDockerFile, isShell]); + function handleVersionChange(version: number) { + if (versions && versions.length > 1) { + if (version < versions[0]) { + setIsRollback(true); + } else { + setIsRollback(false); + } + } + + onVersionChange?.(version); + } + return ( <> -
-
- {!!placeholder && {placeholder}} -
+
+
+
+ {!!placeholder && {placeholder}} +
- - Copy to clipboard - +
+ + Copy to clipboard + +
+
+ {versions && ( +
+
+ +
+
+ )}
{ children?: ReactNode; id: T; hidden?: boolean; + icon?: ComponentProps['icon']; } interface Props { - options: Option[]; + options: Array> | ReadonlyArray>; selectedId?: T; onSelect?(id: T): void; disabled?: boolean; @@ -47,18 +50,16 @@ export function NavTabs({ > {/* rule disabled because `nav-tabs` requires an anchor */} {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - handleSelect(option)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - handleSelect(option); - } - }} - role="button" - tabIndex={0} + as="a" + data-cy="nav-tab-button" + className="!flex" + icon={option.icon} > {option.label} - + ) )} diff --git a/app/react/components/StackVersionSelector/StackVersionSelector.tsx b/app/react/components/StackVersionSelector/StackVersionSelector.tsx new file mode 100644 index 000000000..8ba9d4e76 --- /dev/null +++ b/app/react/components/StackVersionSelector/StackVersionSelector.tsx @@ -0,0 +1,58 @@ +interface Props { + versions?: number[]; + onChange(value: number): void; +} + +export function StackVersionSelector({ versions, onChange }: Props) { + if (!versions || versions.length === 0) { + return null; + } + + const showSelector = versions.length > 1; + + const versionOptions = versions.map((version) => ({ + value: version, + label: version.toString(), + })); + + return ( +
+ {!showSelector && ( + <> + + + {versions[0]} + + + )} + + {showSelector && ( +
+ + +
+ )} +
+ ); +} diff --git a/app/react/components/StackVersionSelector/index.ts b/app/react/components/StackVersionSelector/index.ts new file mode 100644 index 000000000..2cc6697ee --- /dev/null +++ b/app/react/components/StackVersionSelector/index.ts @@ -0,0 +1 @@ +export { StackVersionSelector } from './StackVersionSelector'; diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index a58962710..f8b150e11 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -16,7 +16,7 @@ import { buildConfirmButton } from './modals/utils'; const otherEditorConfig = { tooltip: ( <> -
CtrlF - Start searching
+
Ctrl+F - Start searching
Ctrl+G - Find next
Ctrl+Shift+G - Find previous
Ctrl+Shift+F - Replace
@@ -63,6 +63,8 @@ interface Props extends AutomationTestingProps { titleContent?: React.ReactNode; hideTitle?: boolean; error?: string; + versions?: number[]; + onVersionChange?: (version: number) => void; height?: string; } @@ -77,6 +79,8 @@ export function WebEditorForm({ yaml, children, error, + versions, + onVersionChange, height, 'data-cy': dataCy, }: PropsWithChildren) { @@ -106,6 +110,8 @@ export function WebEditorForm({ yaml={yaml} value={value} onChange={onChange} + versions={versions} + onVersionChange={(v) => onVersionChange && onVersionChange(v)} height={height} data-cy={dataCy} /> diff --git a/app/react/edge/edge-stacks/ItemView/.keep b/app/react/edge/edge-stacks/ItemView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx index 94d477ce2..e31382132 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx @@ -10,9 +10,13 @@ import { FormValues } from './types'; export function ComposeForm({ handleContentChange, hasKubeEndpoint, + handleVersionChange, + versionOptions, }: { hasKubeEndpoint: boolean; handleContentChange: (type: DeploymentType, content: string) => void; + handleVersionChange: (newVersion: number) => void; + versionOptions: number[] | undefined; }) { const { errors, values } = useFormikContext(); @@ -62,6 +66,8 @@ export function ComposeForm({ onChange={(value) => handleContentChange(DeploymentType.Compose, value)} error={errors.content} readonly={hasKubeEndpoint} + versions={versionOptions} + onVersionChange={handleVersionChange} >
You can get more information about Compose file format in the{' '} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx index 85bf4f1f2..911c65e51 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx @@ -1,271 +1,12 @@ -import { Form, Formik, useFormikContext } from 'formik'; -import { useState } from 'react'; -import { array, boolean, number, object, SchemaOf, string } from 'yup'; +import { EdgeStack } from '@/react/edge/edge-stacks/types'; -import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; -import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector'; -import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types'; -import { EnvironmentType } from '@/react/portainer/environments/types'; -import { WebhookSettings } from '@/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings'; -import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; - -import { FormSection } from '@@/form-components/FormSection'; -import { TextTip } from '@@/Tip/TextTip'; -import { SwitchField } from '@@/form-components/SwitchField'; -import { LoadingButton } from '@@/buttons'; -import { FormError } from '@@/form-components/FormError'; -import { - EnvironmentVariablesPanel, - envVarValidation, -} from '@@/form-components/EnvironmentVariablesFieldset'; - -import { PrePullToggle } from '../../components/PrePullToggle'; -import { RetryDeployToggle } from '../../components/RetryDeployToggle'; - -import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper'; -import { FormValues } from './types'; -import { ComposeForm } from './ComposeForm'; -import { KubernetesForm } from './KubernetesForm'; import { GitForm } from './GitForm'; -import { useValidateEnvironmentTypes } from './useEdgeGroupHasType'; +import { NonGitStackForm } from './NonGitStackForm'; -interface Props { - edgeStack: EdgeStack; - isSubmitting: boolean; - onSubmit: (values: FormValues) => void; - onEditorChange: (content: string) => void; - fileContent: string; - allowKubeToSelectCompose: boolean; -} - -const forms = { - [DeploymentType.Compose]: ComposeForm, - [DeploymentType.Kubernetes]: KubernetesForm, -}; - -export function EditEdgeStackForm({ - isSubmitting, - edgeStack, - onSubmit, - onEditorChange, - fileContent, - allowKubeToSelectCompose, -}: Props) { +export function EditEdgeStackForm({ edgeStack }: { edgeStack: EdgeStack }) { if (edgeStack.GitConfig) { return ; } - const formValues: FormValues = { - edgeGroups: edgeStack.EdgeGroups, - deploymentType: edgeStack.DeploymentType, - privateRegistryId: edgeStack.Registries?.[0], - content: fileContent, - useManifestNamespaces: edgeStack.UseManifestNamespaces, - prePullImage: edgeStack.PrePullImage, - retryDeploy: edgeStack.RetryDeploy, - webhookEnabled: !!edgeStack.Webhook, - envVars: edgeStack.EnvVars || [], - }; - - return ( - - - - ); -} - -function InnerForm({ - onEditorChange, - edgeStack, - isSubmitting, - allowKubeToSelectCompose, -}: { - edgeStack: EdgeStack; - isSubmitting: boolean; - onEditorChange: (content: string) => void; - allowKubeToSelectCompose: boolean; -}) { - const { - values, - setFieldValue, - isValid, - - errors, - setFieldError, - } = useFormikContext(); - const { getCachedContent, setContentCache } = useCachedContent(); - const { hasType } = useValidateEnvironmentTypes(values.edgeGroups); - - const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes); - const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker); - - const DeploymentForm = forms[values.deploymentType]; - - return ( -
- setFieldValue('edgeGroups', value)} - error={errors.edgeGroups} - /> - - {hasKubeEndpoint && hasDockerEndpoint && ( - - There are no available deployment types when there is more than one - type of environment in your edge group selection (e.g. Kubernetes and - Docker environments). Please select edge groups that have environments - of the same type. - - )} - - {values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && ( - - Edge groups with kubernetes environments no longer support compose - deployment types in Portainer. Please select edge groups that only - have docker environments when using compose deployment types. - - )} - - { - setFieldValue('content', getCachedContent(value)); - setFieldValue('deploymentType', value); - }} - /> - - - - {isBE && ( - <> - -
-
- setFieldValue('webhookEnabled', value)} - tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack." - /> -
-
- - {edgeStack.Webhook && ( - - )} -
- setFieldValue('privateRegistryId', value)} - values={{ - fileContent: values.content, - }} - onFieldError={(error) => setFieldError('privateRegistryId', error)} - error={errors.privateRegistryId} - /> - - {values.deploymentType === DeploymentType.Compose && ( - <> - setFieldValue('envVars', value)} - values={values.envVars} - errors={errors.envVars} - /> - - setFieldValue('prePullImage', value)} - value={values.prePullImage} - /> - - setFieldValue('retryDeploy', value)} - value={values.retryDeploy} - /> - - )} - - )} - - -
-
- - Update the stack - -
-
-
- - ); - - function handleContentChange(type: DeploymentType, content: string) { - setFieldValue('content', content); - setContentCache(type, content); - onEditorChange(content); - } -} - -function useCachedContent() { - const [cachedContent, setCachedContent] = useState({ - [DeploymentType.Compose]: '', - [DeploymentType.Kubernetes]: '', - }); - - function handleChangeContent(type: DeploymentType, content: string) { - setCachedContent((cache) => ({ ...cache, [type]: content })); - } - - return { - setContentCache: handleChangeContent, - getCachedContent: (type: DeploymentType) => cachedContent[type], - }; -} - -function formValidation(): SchemaOf { - return object({ - content: string().required('Content is required'), - deploymentType: number() - .oneOf([0, 1, 2]) - .required('Deployment type is required'), - privateRegistryId: number().optional(), - prePullImage: boolean().default(false), - retryDeploy: boolean().default(false), - useManifestNamespaces: boolean().default(false), - edgeGroups: array() - .of(number().required()) - .required() - .min(1, 'At least one edge group is required'), - webhookEnabled: boolean().default(false), - envVars: envVarValidation(), - }); + return ; } diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx index 2a7f658d8..0459a1b89 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx @@ -9,8 +9,12 @@ import { FormValues } from './types'; export function KubernetesForm({ handleContentChange, + handleVersionChange, + versionOptions, }: { handleContentChange: (type: DeploymentType, content: string) => void; + handleVersionChange: (version: number) => void; + versionOptions: number[] | undefined; }) { const { errors, values, setFieldValue } = useFormikContext(); @@ -38,6 +42,8 @@ export function KubernetesForm({ handleContentChange(DeploymentType.Kubernetes, value) } error={errors.content} + versions={versionOptions} + onVersionChange={handleVersionChange} >

You can get more information about Kubernetes file format in the{' '} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx new file mode 100644 index 000000000..414ace0c9 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx @@ -0,0 +1,430 @@ +import { Form, Formik, useFormikContext } from 'formik'; +import { useState, useEffect } from 'react'; +import { array, boolean, number, object, SchemaOf, string } from 'yup'; +import { useRouter } from '@uirouter/react'; +import _ from 'lodash'; + +import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; +import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector'; +import { + DeploymentType, + EdgeStack, + StaggerOption, +} from '@/react/edge/edge-stacks/types'; +import { EnvironmentType } from '@/react/portainer/environments/types'; +import { WebhookSettings } from '@/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings'; +import { + baseEdgeStackWebhookUrl, + createWebhookId, +} from '@/portainer/helpers/webhookHelper'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; + +import { FormSection } from '@@/form-components/FormSection'; +import { TextTip } from '@@/Tip/TextTip'; +import { SwitchField } from '@@/form-components/SwitchField'; +import { LoadingButton } from '@@/buttons'; +import { FormError } from '@@/form-components/FormError'; +import { + EnvironmentVariablesPanel, + envVarValidation, +} from '@@/form-components/EnvironmentVariablesFieldset'; +import { usePreventExit } from '@@/WebEditorForm'; + +import { + getEdgeStackFile, + useEdgeStackFile, +} from '../../queries/useEdgeStackFile'; +import { + StaggerFieldset, + staggerConfigValidation, +} from '../../components/StaggerFieldset'; +import { RetryDeployToggle } from '../../components/RetryDeployToggle'; +import { PrePullToggle } from '../../components/PrePullToggle'; +import { getDefaultStaggerConfig } from '../../components/StaggerFieldset.types'; + +import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper'; +import { FormValues } from './types'; +import { useValidateEnvironmentTypes } from './useEdgeGroupHasType'; +import { useStaggerUpdateStatus } from './useStaggerUpdateStatus'; +import { useUpdateEdgeStackMutation } from './useUpdateEdgeStackMutation'; +import { ComposeForm } from './ComposeForm'; +import { KubernetesForm } from './KubernetesForm'; +import { useAllowKubeToSelectCompose } from './useAllowKubeToSelectCompose'; + +const forms = { + [DeploymentType.Compose]: ComposeForm, + [DeploymentType.Kubernetes]: KubernetesForm, +}; + +export function NonGitStackForm({ edgeStack }: { edgeStack: EdgeStack }) { + const mutation = useUpdateEdgeStackMutation(); + const fileQuery = useEdgeStackFile(edgeStack.Id, { skipErrors: true }); + const allowKubeToSelectCompose = useAllowKubeToSelectCompose(edgeStack); + const router = useRouter(); + + if (!fileQuery.isSuccess) { + return null; + } + + const fileContent = fileQuery.data || ''; + + const formValues: FormValues = { + edgeGroups: edgeStack.EdgeGroups, + deploymentType: edgeStack.DeploymentType, + privateRegistryId: edgeStack.Registries?.[0], + content: fileContent, + useManifestNamespaces: edgeStack.UseManifestNamespaces, + prePullImage: edgeStack.PrePullImage, + retryDeploy: edgeStack.RetryDeploy, + webhookEnabled: !!edgeStack.Webhook, + envVars: edgeStack.EnvVars || [], + rollbackTo: undefined, + staggerConfig: edgeStack.StaggerConfig || getDefaultStaggerConfig(), + }; + + const versionOptions = getVersions(edgeStack); + + return ( + + + + ); + + async function handleSubmit(values: FormValues) { + let rePullImage = false; + if (isBE && values.deploymentType === DeploymentType.Compose) { + const defaultToggle = values.prePullImage; + const result = await confirmStackUpdate( + 'Do you want to force an update of the stack?', + defaultToggle + ); + if (!result) { + return; + } + + rePullImage = result.pullImage; + } + + const updateVersion = !!( + fileContent !== values.content || + values.privateRegistryId !== edgeStack.Registries[0] || + values.useManifestNamespaces !== edgeStack.UseManifestNamespaces || + values.prePullImage !== edgeStack.PrePullImage || + values.retryDeploy !== edgeStack.RetryDeploy || + !edgeStack.EnvVars || + _.differenceWith(values.envVars, edgeStack.EnvVars, _.isEqual).length > + 0 || + rePullImage + ); + + mutation.mutate( + { + id: edgeStack.Id, + stackFileContent: values.content, + edgeGroups: values.edgeGroups, + deploymentType: values.deploymentType, + registries: values.privateRegistryId ? [values.privateRegistryId] : [], + useManifestNamespaces: values.useManifestNamespaces, + prePullImage: values.prePullImage, + rePullImage, + retryDeploy: values.retryDeploy, + updateVersion, + webhook: values.webhookEnabled + ? edgeStack.Webhook || createWebhookId() + : undefined, + envVars: values.envVars, + rollbackTo: values.rollbackTo, + staggerConfig: values.staggerConfig, + }, + { + onSuccess: () => { + notifySuccess('Success', 'Stack successfully deployed'); + router.stateService.go('^'); + }, + } + ); + } +} +function getVersions(edgeStack: EdgeStack): Array | undefined { + if (!isBE) { + return undefined; + } + + return _.compact([ + edgeStack.StackFileVersion, + edgeStack.PreviousDeploymentInfo?.FileVersion, + ]); +} + +function InnerForm({ + edgeStack, + isLoading, + allowKubeToSelectCompose, + versionOptions, + isSaved, +}: { + edgeStack: EdgeStack; + isLoading: boolean; + allowKubeToSelectCompose: boolean; + versionOptions: number[] | undefined; + isSaved: boolean; +}) { + const { + values, + setFieldValue, + isValid, + errors, + setFieldError, + initialValues, + } = useFormikContext(); + + usePreventExit(initialValues.content, values.content, !isSaved); + + const { getCachedContent, setContentCache } = useCachedContent(); + const { hasType } = useValidateEnvironmentTypes(values.edgeGroups); + const staggerUpdateStatus = useStaggerUpdateStatus(edgeStack.Id); + const [selectedVersion, setSelectedVersion] = useState(versionOptions?.[0]); + const selectedParallelOption = + values.staggerConfig.StaggerOption === StaggerOption.Parallel; + + useEffect(() => { + if (versionOptions && selectedVersion !== versionOptions[0]) { + setFieldValue('rollbackTo', selectedVersion); + } else { + setFieldValue('rollbackTo', undefined); + } + }, [selectedVersion, setFieldValue, versionOptions]); + + const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes); + const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker); + + if (isBE && !staggerUpdateStatus.isSuccess) { + return null; + } + + const staggerUpdating = + staggerUpdateStatus.data === 'updating' && selectedParallelOption; + + const DeploymentForm = forms[values.deploymentType]; + + return ( +

+ setFieldValue('edgeGroups', value)} + error={errors.edgeGroups} + /> + + {hasKubeEndpoint && hasDockerEndpoint && ( + + There are no available deployment types when there is more than one + type of environment in your edge group selection (e.g. Kubernetes and + Docker environments). Please select edge groups that have environments + of the same type. + + )} + + {values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && ( + + Edge groups with kubernetes environments no longer support compose + deployment types in Portainer. Please select edge groups that only + have docker environments when using compose deployment types. + + )} + + { + setFieldValue('content', getCachedContent(value)); + setFieldValue('deploymentType', value); + }} + /> + + + + {isBE && ( + <> + +
+
+ setFieldValue('webhookEnabled', value)} + tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack." + /> +
+
+ + {edgeStack.Webhook && ( + <> + + + + Sending environment variables to the webhook is updating the + stack with the new values. New variables names will be added + to the stack and existing variables will be updated. + + + )} +
+ + setFieldValue('privateRegistryId', value)} + values={{ + fileContent: values.content, + }} + onFieldError={(error) => setFieldError('privateRegistryId', error)} + error={errors.privateRegistryId} + /> + + {values.deploymentType === DeploymentType.Compose && ( + <> + setFieldValue('envVars', value)} + values={values.envVars} + errors={errors.envVars} + /> + + setFieldValue('prePullImage', value)} + value={values.prePullImage} + /> + + setFieldValue('retryDeploy', value)} + value={values.retryDeploy} + /> + + )} + + + Object.entries(value).forEach(([key, value]) => + setFieldValue(`staggerConfig.${key}`, value) + ) + } + errors={errors.staggerConfig} + /> + + )} + + +
+
+ + Update the stack + +
+ {staggerUpdating && ( +
+ + Concurrent updates in progress, stack update temporarily + unavailable + +
+ )} +
+
+ + ); + + function handleContentChange(type: DeploymentType, content: string) { + setFieldValue('content', content); + setContentCache(type, content); + } + + async function handleVersionChange(newVersion: number) { + if (!versionOptions) { + return; + } + + const fileContent = await getEdgeStackFile(edgeStack.Id, newVersion).catch( + () => '' + ); + if (fileContent) { + if (versionOptions.length > 1) { + if (newVersion < versionOptions[0]) { + setSelectedVersion(newVersion); + } else { + setSelectedVersion(versionOptions[0]); + } + } + handleContentChange(values.deploymentType, fileContent); + } + } +} + +function useCachedContent() { + const [cachedContent, setCachedContent] = useState({ + [DeploymentType.Compose]: '', + [DeploymentType.Kubernetes]: '', + }); + + function handleChangeContent(type: DeploymentType, content: string) { + setCachedContent((cache) => ({ ...cache, [type]: content })); + } + + return { + setContentCache: handleChangeContent, + getCachedContent: (type: DeploymentType) => cachedContent[type], + }; +} + +function formValidation(): SchemaOf { + return object({ + content: string().required('Content is required'), + deploymentType: number() + .oneOf([0, 1, 2]) + .required('Deployment type is required'), + privateRegistryId: number().optional(), + prePullImage: boolean().default(false), + retryDeploy: boolean().default(false), + useManifestNamespaces: boolean().default(false), + edgeGroups: array() + .of(number().required()) + .required() + .min(1, 'At least one edge group is required'), + webhookEnabled: boolean().default(false), + versions: array().of(number().optional()).optional(), + envVars: envVarValidation(), + rollbackTo: number().optional(), + staggerConfig: staggerConfigValidation(), + }); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts index 25401b674..51c7503fe 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts @@ -1,5 +1,5 @@ import { EdgeGroup } from '@/react/edge/edge-groups/types'; -import { DeploymentType } from '@/react/edge/edge-stacks/types'; +import { DeploymentType, StaggerConfig } from '@/react/edge/edge-stacks/types'; import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types'; @@ -13,4 +13,6 @@ export interface FormValues { retryDeploy: boolean; webhookEnabled: boolean; envVars: EnvVar[]; + rollbackTo?: number; + staggerConfig: StaggerConfig; } diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useAllowKubeToSelectCompose.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useAllowKubeToSelectCompose.ts new file mode 100644 index 000000000..57ca78c9d --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useAllowKubeToSelectCompose.ts @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups'; +import { EnvironmentType } from '@/react/portainer/environments/types'; + +import { DeploymentType, EdgeStack } from '../../types'; + +export function useAllowKubeToSelectCompose(edgeStack: EdgeStack) { + const edgeGroupsQuery = useEdgeGroups(); + + const initiallyContainsKubeEnv = _.compact( + edgeStack.EdgeGroups.map( + (id) => edgeGroupsQuery.data?.find((e) => e.Id === id) + ) + ) + .flatMap((group) => group.EndpointTypes) + .includes(EnvironmentType.EdgeAgentOnKubernetes); + + return ( + initiallyContainsKubeEnv && + edgeStack.DeploymentType === DeploymentType.Compose + ); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useStaggerUpdateStatus.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useStaggerUpdateStatus.ts new file mode 100644 index 000000000..38cabb654 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useStaggerUpdateStatus.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +import { EdgeStack } from '../../types'; +import { queryKeys } from '../../queries/query-keys'; +import { buildUrl } from '../../queries/buildUrl'; + +export function staggerStatusQueryKey(edgeStackId: EdgeStack['Id']) { + return [...queryKeys.item(edgeStackId), 'stagger', 'status'] as const; +} + +export function useStaggerUpdateStatus(edgeStackId: EdgeStack['Id']) { + return useQuery( + [...queryKeys.item(edgeStackId), 'stagger-status'], + () => getStaggerStatus(edgeStackId), + { enabled: isBE } + ); +} + +interface StaggerStatusResponse { + status: 'idle' | 'updating'; +} + +async function getStaggerStatus(edgeStackId: EdgeStack['Id']) { + try { + const { data } = await axios.get( + buildUrl(edgeStackId, 'stagger/status') + ); + return data.status; + } catch (error) { + throw parseAxiosError(error as Error, 'Unable to retrieve stagger status'); + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useUpdateEdgeStackMutation.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useUpdateEdgeStackMutation.ts new file mode 100644 index 000000000..5db3b4431 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useUpdateEdgeStackMutation.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; +import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl'; +import { + DeploymentType, + EdgeStack, + StaggerConfig, +} from '@/react/edge/edge-stacks/types'; +import { EdgeGroup } from '@/react/edge/edge-groups/types'; +import { Registry } from '@/react/portainer/registries/types/registry'; +import { Pair } from '@/react/portainer/settings/types'; + +import { queryKeys } from '../../queries/query-keys'; + +export interface UpdateEdgeStackPayload { + id: EdgeStack['Id']; + stackFileContent: string; + edgeGroups: Array; + deploymentType: DeploymentType; + registries: Array; + useManifestNamespaces: boolean; + prePullImage?: boolean; + rePullImage?: boolean; + retryDeploy?: boolean; + updateVersion: boolean; + webhook?: string; + envVars: Pair[]; + rollbackTo?: number; + staggerConfig?: StaggerConfig; +} + +export function useUpdateEdgeStackMutation() { + const queryClient = useQueryClient(); + + return useMutation( + updateEdgeStack, + mutationOptions( + withError('Failed updating stack'), + withInvalidate(queryClient, [queryKeys.base()]) + ) + ); +} + +async function updateEdgeStack({ id, ...payload }: UpdateEdgeStackPayload) { + try { + await axios.put(buildUrl(id), payload); + } catch (err) { + throw parseAxiosError(err as Error, 'Failed updating stack'); + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentsDatatable.tsx b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentsDatatable.tsx index 2ba754b5d..4e9cbe1c3 100644 --- a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentsDatatable.tsx +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentsDatatable.tsx @@ -140,6 +140,7 @@ function getEnvStackStatus( status = { EndpointID: envId, DeploymentInfo: { + Version: 0, ConfigHash: '', FileVersion: 0, }, diff --git a/app/react/edge/edge-stacks/ItemView/ItemView.tsx b/app/react/edge/edge-stacks/ItemView/ItemView.tsx new file mode 100644 index 000000000..e451e6ec8 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/ItemView.tsx @@ -0,0 +1,74 @@ +import { HardDriveIcon, LayersIcon } from 'lucide-react'; + +import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm'; +import { useParamState } from '@/react/hooks/useParamState'; +import { useIdParam } from '@/react/hooks/useIdParam'; + +import { NavTabs } from '@@/NavTabs'; +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; + +import { useEdgeStack } from '../queries/useEdgeStack'; + +import { EnvironmentsDatatable } from './EnvironmentsDatatable'; + +export function ItemView() { + const idParam = useIdParam('stackId'); + const edgeStackQuery = useEdgeStack(idParam); + + const [tab = 'stack', setTab] = useParamState<'stack' | 'environments'>( + 'tab' + ); + + if (!edgeStackQuery.data) { + return null; + } + + const stack = edgeStackQuery.data; + + return ( + <> + + +
+
+ + + + justified + type="pills" + options={[ + { + id: 'stack', + label: 'Stack', + icon: LayersIcon, + children: ( +
+ +
+ ), + }, + { + id: 'environments', + icon: HardDriveIcon, + label: 'Environments', + children: , + }, + ]} + selectedId={tab} + onSelect={setTab} + /> +
+
+
+
+ + ); +} diff --git a/app/react/edge/edge-stacks/queries/query-keys.ts b/app/react/edge/edge-stacks/queries/query-keys.ts index 8af962a37..ae70107d9 100644 --- a/app/react/edge/edge-stacks/queries/query-keys.ts +++ b/app/react/edge/edge-stacks/queries/query-keys.ts @@ -3,4 +3,6 @@ import { EdgeStack } from '../types'; export const queryKeys = { base: () => ['edge-stacks'] as const, item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const, + file: (id: EdgeStack['Id'], version?: number) => + [...queryKeys.item(id), 'file', { version }] as const, }; diff --git a/app/react/edge/edge-stacks/queries/useEdgeStackFile.ts b/app/react/edge/edge-stacks/queries/useEdgeStackFile.ts new file mode 100644 index 000000000..945c5bf05 --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useEdgeStackFile.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { EdgeStack } from '../types'; + +import { buildUrl } from './buildUrl'; +import { queryKeys } from './query-keys'; + +export function useEdgeStackFile( + id: EdgeStack['Id'], + { skipErrors, version }: { version?: number; skipErrors?: boolean } = {} +) { + return useQuery({ + queryKey: queryKeys.file(id, version), + queryFn: () => + getEdgeStackFile(id, version).catch((e) => { + if (!skipErrors) { + throw e; + } + + return ''; + }), + }); +} + +interface StackFileResponse { + StackFileContent: string; +} + +export async function getEdgeStackFile(id?: EdgeStack['Id'], version?: number) { + if (!id) { + return null; + } + + try { + const { data } = await axios.get(buildUrl(id, 'file'), { + params: { version }, + }); + return data.StackFileContent; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts index 49d2563e9..f851660fe 100644 --- a/app/react/edge/edge-stacks/types.ts +++ b/app/react/edge/edge-stacks/types.ts @@ -10,6 +10,8 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types'; import { EdgeGroup } from '../edge-groups/types'; +import { type StaggerConfig } from './components/StaggerFieldset.types'; + export { type StaggerConfig, StaggerOption, @@ -55,6 +57,7 @@ export interface DeploymentStatus { } interface EdgeStackDeploymentInfo { + Version: number; FileVersion: number; ConfigHash: string; } @@ -94,9 +97,11 @@ export type EdgeStack = RelativePathModel & { GitConfig?: RepoConfigResponse; Prune: boolean; RetryDeploy: boolean; - Webhook?: string; + Webhook: string; StackFileVersion?: number; + PreviousDeploymentInfo: EdgeStackDeploymentInfo; EnvVars?: EnvVar[]; + StaggerConfig?: StaggerConfig; SupportRelativePath: boolean; FilesystemPath?: string; }; diff --git a/app/react/hooks/useIdParam.ts b/app/react/hooks/useIdParam.ts new file mode 100644 index 000000000..1ada9295d --- /dev/null +++ b/app/react/hooks/useIdParam.ts @@ -0,0 +1,13 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +export function useIdParam(param = 'id'): number { + const { params } = useCurrentStateAndParams(); + + const stringId = params[param]; + const id = parseInt(stringId, 10); + if (!id || Number.isNaN(id)) { + throw new Error('id url param is required'); + } + + return id; +} diff --git a/app/react/hooks/useParamState.ts b/app/react/hooks/useParamState.ts index 8125d6c99..98f122e10 100644 --- a/app/react/hooks/useParamState.ts +++ b/app/react/hooks/useParamState.ts @@ -14,7 +14,7 @@ export function useParamState( return [ state, (value?: T) => { - router.stateService.go('.', { [param]: value }); + router.stateService.go('.', { [param]: value }, {}); }, ] as const; } diff --git a/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx index 082bf2cc5..329cc7419 100644 --- a/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx +++ b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx @@ -51,7 +51,7 @@ export function InnerForm({ isSubmitting, dirty, } = useFormikContext(); - + console.log({ isEditorReadonly, isSubmitting, isLoading }); usePreventExit( initialValues.FileContent, values.FileContent,