refactor(edge/stacks): migrate edit view to react [EE-2222] (#11648)

pull/11033/head
Chaim Lev-Ari 2024-05-09 18:02:20 +03:00 committed by GitHub
parent 27e309754e
commit cd5f342da0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 847 additions and 499 deletions

View File

@ -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: {

View File

@ -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), [

View File

@ -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;

View File

@ -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)), [])

View File

@ -1,39 +0,0 @@
<page-header title="'Edit Edge stack'" breadcrumbs="[{label:'Edge Stacks', link:'edge.stacks'}, $ctrl.stack.Name]" reload="true"> </page-header>
<div class="row" ng-if="$ctrl.stack">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body classes="no-padding">
<uib-tabset active="$ctrl.state.activeTab" justified="true" type="pills">
<uib-tab index="0" classes="btn-sm">
<uib-tab-heading>
<span><pr-icon icon="'layers'"></pr-icon></span> Stack
</uib-tab-heading>
<div style="padding: 20px">
<edit-edge-stack-form
ng-if="$ctrl.edgeGroups && $ctrl.stack && $ctrl.formValues.content !== null"
edge-groups="$ctrl.edgeGroups"
edge-stack="$ctrl.stack"
is-submitting="$ctrl.state.actionInProgress"
on-submit="($ctrl.deployStack)"
on-editor-change="($ctrl.onEditorChange)"
file-content="$ctrl.formValues.content"
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"
></edit-edge-stack-form>
</div>
</uib-tab>
<uib-tab index="1" classes="btn-sm">
<uib-tab-heading>
<span><pr-icon icon="'hard-drive'"></pr-icon></span> Environments
</uib-tab-heading>
<div style="margin-top: 25px">
<edge-stack-environments-datatable></edge-stack-environments-datatable>
</div>
</uib-tab>
</uib-tabset>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -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;
}
}
}

View File

@ -1,8 +0,0 @@
import angular from 'angular';
import { EditEdgeStackViewController } from './editEdgeStackViewController';
angular.module('portainer.edge').component('editEdgeStackView', {
templateUrl: './editEdgeStackView.html',
controller: EditEdgeStackViewController,
});

View File

@ -1,3 +0,0 @@
import angular from 'angular';
export default angular.module('portainer.edge.stacks', []).name;

View File

@ -229,6 +229,8 @@ export const ngModule = angular
'value',
'height',
'data-cy',
'versions',
'onVersionChange',
])
)
.component(

View File

@ -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,13 +92,27 @@ 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 (
<>
<div className="mb-2 flex items-center justify-between">
<div className="flex flex-1 items-center">
<div className="mb-2 flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center">
{!!placeholder && <TextTip color="blue">{placeholder}</TextTip>}
</div>
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
<CopyButton
data-cy={`copy-code-button-${id}`}
fadeDelay={2500}
@ -103,12 +124,24 @@ export function CodeEditor({
Copy to clipboard
</CopyButton>
</div>
</div>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
<CodeMirror
className={styles.root}
theme={theme}
value={value}
onChange={onChange}
readOnly={readonly}
readOnly={readonly || isRollback}
id={id}
extensions={extensions}
height={height}

View File

@ -1,5 +1,7 @@
import clsx from 'clsx';
import { ReactNode } from 'react';
import { ComponentProps, ReactNode } from 'react';
import { Button } from '@@/buttons';
import styles from './NavTabs.module.css';
@ -8,10 +10,11 @@ export interface Option<T extends string | number = string> {
children?: ReactNode;
id: T;
hidden?: boolean;
icon?: ComponentProps<typeof Button>['icon'];
}
interface Props<T extends string | number> {
options: Option<T>[];
options: Array<Option<T>> | ReadonlyArray<Option<T>>;
selectedId?: T;
onSelect?(id: T): void;
disabled?: boolean;
@ -47,18 +50,16 @@ export function NavTabs<T extends string | number = string>({
>
{/* rule disabled because `nav-tabs` requires an anchor */}
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
<Button
color="none"
onClick={() => 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}
</a>
</Button>
</li>
)
)}

View File

@ -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 (
<div className="flex">
{!showSelector && (
<>
<label className="text-muted mr-2" htmlFor="version_id">
<span>Version:</span>
</label>
<span className="text-muted" id="version_id">
{versions[0]}
</span>
</>
)}
{showSelector && (
<div className="text-muted">
<label className="mr-2" htmlFor="version_id">
<span>Version:</span>
</label>
<select
className="form-select"
data-cy="version-selector"
style={{
width: '60px',
height: '24px',
borderRadius: '4px',
borderColor: 'hsl(0, 0%, 80%)',
padding: '2px 8px',
}}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
>
{versionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.value}
</option>
))}
</select>
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { StackVersionSelector } from './StackVersionSelector';

View File

@ -16,7 +16,7 @@ import { buildConfirmButton } from './modals/utils';
const otherEditorConfig = {
tooltip: (
<>
<div>CtrlF - Start searching</div>
<div>Ctrl+F - Start searching</div>
<div>Ctrl+G - Find next</div>
<div>Ctrl+Shift+G - Find previous</div>
<div>Ctrl+Shift+F - Replace</div>
@ -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<Props>) {
@ -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}
/>

View File

@ -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<FormValues>();
@ -62,6 +66,8 @@ export function ComposeForm({
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
error={errors.content}
readonly={hasKubeEndpoint}
versions={versionOptions}
onVersionChange={handleVersionChange}
>
<div>
You can get more information about Compose file format in the{' '}

View File

@ -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 <GitForm stack={edgeStack} />;
}
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 (
<Formik
initialValues={formValues}
onSubmit={onSubmit}
validationSchema={formValidation()}
>
<InnerForm
edgeStack={edgeStack}
isSubmitting={isSubmitting}
onEditorChange={onEditorChange}
allowKubeToSelectCompose={allowKubeToSelectCompose}
/>
</Formik>
);
}
function InnerForm({
onEditorChange,
edgeStack,
isSubmitting,
allowKubeToSelectCompose,
}: {
edgeStack: EdgeStack;
isSubmitting: boolean;
onEditorChange: (content: string) => void;
allowKubeToSelectCompose: boolean;
}) {
const {
values,
setFieldValue,
isValid,
errors,
setFieldError,
} = useFormikContext<FormValues>();
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 (
<Form className="form-horizontal">
<EdgeGroupsSelector
value={values.edgeGroups}
onChange={(value) => setFieldValue('edgeGroups', value)}
error={errors.edgeGroups}
/>
{hasKubeEndpoint && hasDockerEndpoint && (
<TextTip>
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.
</TextTip>
)}
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
<FormError>
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.
</FormError>
)}
<EdgeStackDeploymentTypeSelector
allowKubeToSelectCompose={allowKubeToSelectCompose}
value={values.deploymentType}
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
onChange={(value) => {
setFieldValue('content', getCachedContent(value));
setFieldValue('deploymentType', value);
}}
/>
<DeploymentForm
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
handleContentChange={handleContentChange}
/>
{isBE && (
<>
<FormSection title="Webhooks">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Create an Edge stack webhook"
data-cy="edge-stack-enable-webhook-switch"
checked={values.webhookEnabled}
labelClass="col-sm-3 col-lg-2"
onChange={(value) => 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."
/>
</div>
</div>
{edgeStack.Webhook && (
<WebhookSettings
baseUrl={baseEdgeStackWebhookUrl()}
value={edgeStack.Webhook}
docsLink="todo"
/>
)}
</FormSection>
<PrivateRegistryFieldsetWrapper
value={values.privateRegistryId}
onChange={(value) => setFieldValue('privateRegistryId', value)}
values={{
fileContent: values.content,
}}
onFieldError={(error) => setFieldError('privateRegistryId', error)}
error={errors.privateRegistryId}
/>
{values.deploymentType === DeploymentType.Compose && (
<>
<EnvironmentVariablesPanel
onChange={(value) => setFieldValue('envVars', value)}
values={values.envVars}
errors={errors.envVars}
/>
<PrePullToggle
onChange={(value) => setFieldValue('prePullImage', value)}
value={values.prePullImage}
/>
<RetryDeployToggle
onChange={(value) => setFieldValue('retryDeploy', value)}
value={values.retryDeploy}
/>
</>
)}
</>
)}
<FormSection title="Actions">
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
className="!ml-0"
data-cy="update-stack-button"
size="small"
disabled={!isValid}
isLoading={isSubmitting}
button-spinner="$ctrl.actionInProgress"
loadingText="Update in progress..."
>
Update the stack
</LoadingButton>
</div>
</div>
</FormSection>
</Form>
);
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<FormValues> {
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 <NonGitStackForm edgeStack={edgeStack} />;
}

View File

@ -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<FormValues>();
@ -38,6 +42,8 @@ export function KubernetesForm({
handleContentChange(DeploymentType.Kubernetes, value)
}
error={errors.content}
versions={versionOptions}
onVersionChange={handleVersionChange}
>
<p>
You can get more information about Kubernetes file format in the{' '}

View File

@ -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 (
<Formik
initialValues={formValues}
onSubmit={handleSubmit}
validationSchema={formValidation()}
>
<InnerForm
edgeStack={edgeStack}
isLoading={mutation.isLoading}
allowKubeToSelectCompose={allowKubeToSelectCompose}
versionOptions={versionOptions}
isSaved={mutation.isSuccess}
/>
</Formik>
);
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<number> | 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<FormValues>();
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 (
<Form className="form-horizontal">
<EdgeGroupsSelector
value={values.edgeGroups}
onChange={(value) => setFieldValue('edgeGroups', value)}
error={errors.edgeGroups}
/>
{hasKubeEndpoint && hasDockerEndpoint && (
<TextTip>
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.
</TextTip>
)}
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
<FormError>
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.
</FormError>
)}
<EdgeStackDeploymentTypeSelector
allowKubeToSelectCompose={allowKubeToSelectCompose}
value={values.deploymentType}
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
onChange={(value) => {
setFieldValue('content', getCachedContent(value));
setFieldValue('deploymentType', value);
}}
/>
<DeploymentForm
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
handleContentChange={handleContentChange}
versionOptions={versionOptions}
handleVersionChange={handleVersionChange}
/>
{isBE && (
<>
<FormSection title="Webhooks">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Create an Edge stack webhook"
data-cy="edge-stack-enable-webhook-switch"
checked={values.webhookEnabled}
labelClass="col-sm-3 col-lg-2"
onChange={(value) => 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."
/>
</div>
</div>
{edgeStack.Webhook && (
<>
<WebhookSettings
baseUrl={baseEdgeStackWebhookUrl()}
value={edgeStack.Webhook}
docsLink=""
/>
<TextTip color="orange">
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.
</TextTip>
</>
)}
</FormSection>
<PrivateRegistryFieldsetWrapper
value={values.privateRegistryId}
onChange={(value) => setFieldValue('privateRegistryId', value)}
values={{
fileContent: values.content,
}}
onFieldError={(error) => setFieldError('privateRegistryId', error)}
error={errors.privateRegistryId}
/>
{values.deploymentType === DeploymentType.Compose && (
<>
<EnvironmentVariablesPanel
onChange={(value) => setFieldValue('envVars', value)}
values={values.envVars}
errors={errors.envVars}
/>
<PrePullToggle
onChange={(value) => setFieldValue('prePullImage', value)}
value={values.prePullImage}
/>
<RetryDeployToggle
onChange={(value) => setFieldValue('retryDeploy', value)}
value={values.retryDeploy}
/>
</>
)}
<StaggerFieldset
values={values.staggerConfig}
onChange={(value) =>
Object.entries(value).forEach(([key, value]) =>
setFieldValue(`staggerConfig.${key}`, value)
)
}
errors={errors.staggerConfig}
/>
</>
)}
<FormSection title="Actions">
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
className="!ml-0"
data-cy="update-stack-button"
size="small"
disabled={!isValid || staggerUpdating}
isLoading={isLoading}
button-spinner="$ctrl.actionInProgress"
loadingText="Update in progress..."
>
Update the stack
</LoadingButton>
</div>
{staggerUpdating && (
<div className="col-sm-12">
<FormError>
Concurrent updates in progress, stack update temporarily
unavailable
</FormError>
</div>
)}
</div>
</FormSection>
</Form>
);
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<FormValues> {
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(),
});
}

View File

@ -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;
}

View File

@ -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
);
}

View File

@ -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<StaggerStatusResponse>(
buildUrl(edgeStackId, 'stagger/status')
);
return data.status;
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve stagger status');
}
}

View File

@ -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<EdgeGroup['Id']>;
deploymentType: DeploymentType;
registries: Array<Registry['Id']>;
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');
}
}

View File

@ -140,6 +140,7 @@ function getEnvStackStatus(
status = {
EndpointID: envId,
DeploymentInfo: {
Version: 0,
ConfigHash: '',
FileVersion: 0,
},

View File

@ -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 (
<>
<PageHeader
title="Edit Edge stack"
breadcrumbs={[
{ label: 'Edge Stacks', link: 'edge.stacks' },
stack.Name,
]}
reload
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body className="!p-0">
<NavTabs<'stack' | 'environments'>
justified
type="pills"
options={[
{
id: 'stack',
label: 'Stack',
icon: LayersIcon,
children: (
<div className="p-5 pb-10">
<EditEdgeStackForm edgeStack={stack} />
</div>
),
},
{
id: 'environments',
icon: HardDriveIcon,
label: 'Environments',
children: <EnvironmentsDatatable />,
},
]}
selectedId={tab}
onSelect={setTab}
/>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}

View File

@ -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,
};

View File

@ -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<StackFileResponse>(buildUrl(id, 'file'), {
params: { version },
});
return data.StackFileContent;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -14,7 +14,7 @@ export function useParamState<T>(
return [
state,
(value?: T) => {
router.stateService.go('.', { [param]: value });
router.stateService.go('.', { [param]: value }, {});
},
] as const;
}

View File

@ -51,7 +51,7 @@ export function InnerForm({
isSubmitting,
dirty,
} = useFormikContext<FormValues>();
console.log({ isEditorReadonly, isSubmitting, isLoading });
usePreventExit(
initialValues.FileContent,
values.FileContent,