mirror of https://github.com/portainer/portainer
refactor(edge/stacks): migrate edit view to react [EE-2222] (#11648)
parent
27e309754e
commit
cd5f342da0
|
@ -1,11 +1,10 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import { AccessHeaders } from '@/portainer/authorization-guard';
|
import { AccessHeaders } from '@/portainer/authorization-guard';
|
||||||
import edgeStackModule from './views/edge-stacks';
|
|
||||||
import { reactModule } from './react';
|
import { reactModule } from './react';
|
||||||
|
|
||||||
angular
|
angular
|
||||||
.module('portainer.edge', [edgeStackModule, reactModule])
|
.module('portainer.edge', [reactModule])
|
||||||
|
|
||||||
.config(function config($stateRegistryProvider) {
|
.config(function config($stateRegistryProvider) {
|
||||||
const edge = {
|
const edge = {
|
||||||
|
@ -82,7 +81,7 @@ angular
|
||||||
url: '/:stackId?tab&status',
|
url: '/:stackId?tab&status',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'editEdgeStackView',
|
component: 'edgeStacksItemView',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
params: {
|
params: {
|
||||||
|
|
|
@ -6,22 +6,15 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
||||||
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
||||||
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
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 { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
|
||||||
|
|
||||||
import { edgeJobsModule } from './edge-jobs';
|
import { edgeJobsModule } from './edge-jobs';
|
||||||
|
|
||||||
const ngModule = angular
|
const ngModule = angular
|
||||||
.module('portainer.edge.react.components', [edgeJobsModule])
|
.module('portainer.edge.react.components', [edgeJobsModule])
|
||||||
.component(
|
|
||||||
'edgeStackEnvironmentsDatatable',
|
|
||||||
r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), [])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'edgeGroupsSelector',
|
'edgeGroupsSelector',
|
||||||
r2a(withUIRouter(withReactQuery(EdgeGroupsSelector)), [
|
r2a(withUIRouter(withReactQuery(EdgeGroupsSelector)), [
|
||||||
|
@ -64,27 +57,6 @@ const ngModule = angular
|
||||||
'fieldSettings',
|
'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(
|
.component(
|
||||||
'edgeGroupAssociationTable',
|
'edgeGroupAssociationTable',
|
||||||
r2a(withReactQuery(EdgeGroupAssociationTable), [
|
r2a(withReactQuery(EdgeGroupAssociationTable), [
|
||||||
|
|
|
@ -4,10 +4,20 @@ import { r2a } from '@/react-tools/react2angular';
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { CreateView } from '@/react/edge/edge-stacks/CreateView/CreateView';
|
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
|
export const stacksModule = angular
|
||||||
.module('portainer.edge.react.views.stacks', [])
|
.module('portainer.edge.react.views.stacks', [])
|
||||||
.component(
|
.component(
|
||||||
'edgeStacksCreateView',
|
'edgeStacksCreateView',
|
||||||
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'edgeStacksItemView',
|
||||||
|
r2a(withCurrentUser(withUIRouter(ItemView)), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'edgeStacksView',
|
||||||
|
r2a(withUIRouter(withCurrentUser(ListView)), [])
|
||||||
).name;
|
).name;
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { WaitingRoomView } from '@/react/edge/edge-devices/WaitingRoomView';
|
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 { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView';
|
||||||
|
|
||||||
import { templatesModule } from './templates';
|
import { templatesModule } from './templates';
|
||||||
|
@ -22,10 +21,6 @@ export const viewsModule = angular
|
||||||
'waitingRoomView',
|
'waitingRoomView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])
|
||||||
)
|
)
|
||||||
.component(
|
|
||||||
'edgeStacksView',
|
|
||||||
r2a(withUIRouter(withCurrentUser(EdgeStacksListView)), [])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'edgeGroupsView',
|
'edgeGroupsView',
|
||||||
r2a(withUIRouter(withCurrentUser(EdgeGroupsListView)), [])
|
r2a(withUIRouter(withCurrentUser(EdgeGroupsListView)), [])
|
||||||
|
|
|
@ -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>
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
import { EditEdgeStackViewController } from './editEdgeStackViewController';
|
|
||||||
|
|
||||||
angular.module('portainer.edge').component('editEdgeStackView', {
|
|
||||||
templateUrl: './editEdgeStackView.html',
|
|
||||||
controller: EditEdgeStackViewController,
|
|
||||||
});
|
|
|
@ -1,3 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
export default angular.module('portainer.edge.stacks', []).name;
|
|
|
@ -229,6 +229,8 @@ export const ngModule = angular
|
||||||
'value',
|
'value',
|
||||||
'height',
|
'height',
|
||||||
'data-cy',
|
'data-cy',
|
||||||
|
'versions',
|
||||||
|
'onVersionChange',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { createTheme } from '@uiw/codemirror-themes';
|
import { createTheme } from '@uiw/codemirror-themes';
|
||||||
import { tags as highlightTags } from '@lezer/highlight';
|
import { tags as highlightTags } from '@lezer/highlight';
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ import { CopyButton } from '@@/buttons/CopyButton';
|
||||||
|
|
||||||
import styles from './CodeEditor.module.css';
|
import styles from './CodeEditor.module.css';
|
||||||
import { TextTip } from './Tip/TextTip';
|
import { TextTip } from './Tip/TextTip';
|
||||||
|
import { StackVersionSelector } from './StackVersionSelector';
|
||||||
|
|
||||||
interface Props extends AutomationTestingProps {
|
interface Props extends AutomationTestingProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -24,6 +25,8 @@ interface Props extends AutomationTestingProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
value: string;
|
value: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
|
versions?: number[];
|
||||||
|
onVersionChange?: (version: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
|
@ -65,12 +68,16 @@ export function CodeEditor({
|
||||||
placeholder,
|
placeholder,
|
||||||
readonly,
|
readonly,
|
||||||
value,
|
value,
|
||||||
|
versions,
|
||||||
|
onVersionChange,
|
||||||
height = '500px',
|
height = '500px',
|
||||||
yaml: isYaml,
|
yaml: isYaml,
|
||||||
dockerFile: isDockerFile,
|
dockerFile: isDockerFile,
|
||||||
shell: isShell,
|
shell: isShell,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [isRollback, setIsRollback] = useState(false);
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
const extensions = [];
|
const extensions = [];
|
||||||
if (isYaml) {
|
if (isYaml) {
|
||||||
|
@ -85,13 +92,27 @@ export function CodeEditor({
|
||||||
return extensions;
|
return extensions;
|
||||||
}, [isYaml, isDockerFile, isShell]);
|
}, [isYaml, isDockerFile, isShell]);
|
||||||
|
|
||||||
|
function handleVersionChange(version: number) {
|
||||||
|
if (versions && versions.length > 1) {
|
||||||
|
if (version < versions[0]) {
|
||||||
|
setIsRollback(true);
|
||||||
|
} else {
|
||||||
|
setIsRollback(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onVersionChange?.(version);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex flex-col">
|
||||||
<div className="flex flex-1 items-center">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
{!!placeholder && <TextTip color="blue">{placeholder}</TextTip>}
|
{!!placeholder && <TextTip color="blue">{placeholder}</TextTip>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
|
||||||
<CopyButton
|
<CopyButton
|
||||||
data-cy={`copy-code-button-${id}`}
|
data-cy={`copy-code-button-${id}`}
|
||||||
fadeDelay={2500}
|
fadeDelay={2500}
|
||||||
|
@ -103,12 +124,24 @@ export function CodeEditor({
|
||||||
Copy to clipboard
|
Copy to clipboard
|
||||||
</CopyButton>
|
</CopyButton>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{versions && (
|
||||||
|
<div className="mt-2 flex">
|
||||||
|
<div className="ml-auto mr-2">
|
||||||
|
<StackVersionSelector
|
||||||
|
versions={versions}
|
||||||
|
onChange={handleVersionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
className={styles.root}
|
className={styles.root}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
readOnly={readonly}
|
readOnly={readonly || isRollback}
|
||||||
id={id}
|
id={id}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
height={height}
|
height={height}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ReactNode } from 'react';
|
import { ComponentProps, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import styles from './NavTabs.module.css';
|
import styles from './NavTabs.module.css';
|
||||||
|
|
||||||
|
@ -8,10 +10,11 @@ export interface Option<T extends string | number = string> {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
id: T;
|
id: T;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
icon?: ComponentProps<typeof Button>['icon'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T extends string | number> {
|
interface Props<T extends string | number> {
|
||||||
options: Option<T>[];
|
options: Array<Option<T>> | ReadonlyArray<Option<T>>;
|
||||||
selectedId?: T;
|
selectedId?: T;
|
||||||
onSelect?(id: T): void;
|
onSelect?(id: T): void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -47,18 +50,16 @@ export function NavTabs<T extends string | number = string>({
|
||||||
>
|
>
|
||||||
{/* rule disabled because `nav-tabs` requires an anchor */}
|
{/* rule disabled because `nav-tabs` requires an anchor */}
|
||||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||||
<a
|
<Button
|
||||||
|
color="none"
|
||||||
onClick={() => handleSelect(option)}
|
onClick={() => handleSelect(option)}
|
||||||
onKeyDown={(e) => {
|
as="a"
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
data-cy="nav-tab-button"
|
||||||
handleSelect(option);
|
className="!flex"
|
||||||
}
|
icon={option.icon}
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</a>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { StackVersionSelector } from './StackVersionSelector';
|
|
@ -16,7 +16,7 @@ import { buildConfirmButton } from './modals/utils';
|
||||||
const otherEditorConfig = {
|
const otherEditorConfig = {
|
||||||
tooltip: (
|
tooltip: (
|
||||||
<>
|
<>
|
||||||
<div>CtrlF - Start searching</div>
|
<div>Ctrl+F - Start searching</div>
|
||||||
<div>Ctrl+G - Find next</div>
|
<div>Ctrl+G - Find next</div>
|
||||||
<div>Ctrl+Shift+G - Find previous</div>
|
<div>Ctrl+Shift+G - Find previous</div>
|
||||||
<div>Ctrl+Shift+F - Replace</div>
|
<div>Ctrl+Shift+F - Replace</div>
|
||||||
|
@ -63,6 +63,8 @@ interface Props extends AutomationTestingProps {
|
||||||
titleContent?: React.ReactNode;
|
titleContent?: React.ReactNode;
|
||||||
hideTitle?: boolean;
|
hideTitle?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
versions?: number[];
|
||||||
|
onVersionChange?: (version: number) => void;
|
||||||
height?: string;
|
height?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,6 +79,8 @@ export function WebEditorForm({
|
||||||
yaml,
|
yaml,
|
||||||
children,
|
children,
|
||||||
error,
|
error,
|
||||||
|
versions,
|
||||||
|
onVersionChange,
|
||||||
height,
|
height,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
|
@ -106,6 +110,8 @@ export function WebEditorForm({
|
||||||
yaml={yaml}
|
yaml={yaml}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
versions={versions}
|
||||||
|
onVersionChange={(v) => onVersionChange && onVersionChange(v)}
|
||||||
height={height}
|
height={height}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -10,9 +10,13 @@ import { FormValues } from './types';
|
||||||
export function ComposeForm({
|
export function ComposeForm({
|
||||||
handleContentChange,
|
handleContentChange,
|
||||||
hasKubeEndpoint,
|
hasKubeEndpoint,
|
||||||
|
handleVersionChange,
|
||||||
|
versionOptions,
|
||||||
}: {
|
}: {
|
||||||
hasKubeEndpoint: boolean;
|
hasKubeEndpoint: boolean;
|
||||||
handleContentChange: (type: DeploymentType, content: string) => void;
|
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||||
|
handleVersionChange: (newVersion: number) => void;
|
||||||
|
versionOptions: number[] | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { errors, values } = useFormikContext<FormValues>();
|
const { errors, values } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
@ -62,6 +66,8 @@ export function ComposeForm({
|
||||||
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
|
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
|
||||||
error={errors.content}
|
error={errors.content}
|
||||||
readonly={hasKubeEndpoint}
|
readonly={hasKubeEndpoint}
|
||||||
|
versions={versionOptions}
|
||||||
|
onVersionChange={handleVersionChange}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
You can get more information about Compose file format in the{' '}
|
You can get more information about Compose file format in the{' '}
|
||||||
|
|
|
@ -1,271 +1,12 @@
|
||||||
import { Form, Formik, useFormikContext } from 'formik';
|
import { EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||||
import { useState } from 'react';
|
|
||||||
import { array, boolean, number, object, SchemaOf, string } from 'yup';
|
|
||||||
|
|
||||||
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 { GitForm } from './GitForm';
|
||||||
import { useValidateEnvironmentTypes } from './useEdgeGroupHasType';
|
import { NonGitStackForm } from './NonGitStackForm';
|
||||||
|
|
||||||
interface Props {
|
export function EditEdgeStackForm({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||||
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) {
|
|
||||||
if (edgeStack.GitConfig) {
|
if (edgeStack.GitConfig) {
|
||||||
return <GitForm stack={edgeStack} />;
|
return <GitForm stack={edgeStack} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formValues: FormValues = {
|
return <NonGitStackForm edgeStack={edgeStack} />;
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,12 @@ import { FormValues } from './types';
|
||||||
|
|
||||||
export function KubernetesForm({
|
export function KubernetesForm({
|
||||||
handleContentChange,
|
handleContentChange,
|
||||||
|
handleVersionChange,
|
||||||
|
versionOptions,
|
||||||
}: {
|
}: {
|
||||||
handleContentChange: (type: DeploymentType, content: string) => void;
|
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||||
|
handleVersionChange: (version: number) => void;
|
||||||
|
versionOptions: number[] | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { errors, values, setFieldValue } = useFormikContext<FormValues>();
|
const { errors, values, setFieldValue } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
@ -38,6 +42,8 @@ export function KubernetesForm({
|
||||||
handleContentChange(DeploymentType.Kubernetes, value)
|
handleContentChange(DeploymentType.Kubernetes, value)
|
||||||
}
|
}
|
||||||
error={errors.content}
|
error={errors.content}
|
||||||
|
versions={versionOptions}
|
||||||
|
onVersionChange={handleVersionChange}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
You can get more information about Kubernetes file format in the{' '}
|
You can get more information about Kubernetes file format in the{' '}
|
||||||
|
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
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';
|
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
||||||
|
|
||||||
|
@ -13,4 +13,6 @@ export interface FormValues {
|
||||||
retryDeploy: boolean;
|
retryDeploy: boolean;
|
||||||
webhookEnabled: boolean;
|
webhookEnabled: boolean;
|
||||||
envVars: EnvVar[];
|
envVars: EnvVar[];
|
||||||
|
rollbackTo?: number;
|
||||||
|
staggerConfig: StaggerConfig;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
|
@ -140,6 +140,7 @@ function getEnvStackStatus(
|
||||||
status = {
|
status = {
|
||||||
EndpointID: envId,
|
EndpointID: envId,
|
||||||
DeploymentInfo: {
|
DeploymentInfo: {
|
||||||
|
Version: 0,
|
||||||
ConfigHash: '',
|
ConfigHash: '',
|
||||||
FileVersion: 0,
|
FileVersion: 0,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -3,4 +3,6 @@ import { EdgeStack } from '../types';
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
base: () => ['edge-stacks'] as const,
|
base: () => ['edge-stacks'] as const,
|
||||||
item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const,
|
item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const,
|
||||||
|
file: (id: EdgeStack['Id'], version?: number) =>
|
||||||
|
[...queryKeys.item(id), 'file', { version }] as const,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,8 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
||||||
|
|
||||||
import { EdgeGroup } from '../edge-groups/types';
|
import { EdgeGroup } from '../edge-groups/types';
|
||||||
|
|
||||||
|
import { type StaggerConfig } from './components/StaggerFieldset.types';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type StaggerConfig,
|
type StaggerConfig,
|
||||||
StaggerOption,
|
StaggerOption,
|
||||||
|
@ -55,6 +57,7 @@ export interface DeploymentStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EdgeStackDeploymentInfo {
|
interface EdgeStackDeploymentInfo {
|
||||||
|
Version: number;
|
||||||
FileVersion: number;
|
FileVersion: number;
|
||||||
ConfigHash: string;
|
ConfigHash: string;
|
||||||
}
|
}
|
||||||
|
@ -94,9 +97,11 @@ export type EdgeStack = RelativePathModel & {
|
||||||
GitConfig?: RepoConfigResponse;
|
GitConfig?: RepoConfigResponse;
|
||||||
Prune: boolean;
|
Prune: boolean;
|
||||||
RetryDeploy: boolean;
|
RetryDeploy: boolean;
|
||||||
Webhook?: string;
|
Webhook: string;
|
||||||
StackFileVersion?: number;
|
StackFileVersion?: number;
|
||||||
|
PreviousDeploymentInfo: EdgeStackDeploymentInfo;
|
||||||
EnvVars?: EnvVar[];
|
EnvVars?: EnvVar[];
|
||||||
|
StaggerConfig?: StaggerConfig;
|
||||||
SupportRelativePath: boolean;
|
SupportRelativePath: boolean;
|
||||||
FilesystemPath?: string;
|
FilesystemPath?: string;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ export function useParamState<T>(
|
||||||
return [
|
return [
|
||||||
state,
|
state,
|
||||||
(value?: T) => {
|
(value?: T) => {
|
||||||
router.stateService.go('.', { [param]: value });
|
router.stateService.go('.', { [param]: value }, {});
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,7 +51,7 @@ export function InnerForm({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
dirty,
|
dirty,
|
||||||
} = useFormikContext<FormValues>();
|
} = useFormikContext<FormValues>();
|
||||||
|
console.log({ isEditorReadonly, isSubmitting, isLoading });
|
||||||
usePreventExit(
|
usePreventExit(
|
||||||
initialValues.FileContent,
|
initialValues.FileContent,
|
||||||
values.FileContent,
|
values.FileContent,
|
||||||
|
|
Loading…
Reference in New Issue