();
@@ -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 (
+
+ );
+
+ 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,