From d0a985fa869acb8920e95ec01f4647fc333b2e91 Mon Sep 17 00:00:00 2001 From: linghaoSu Date: Wed, 27 Nov 2024 17:51:58 +0800 Subject: [PATCH] fix(ui): extract common code form terminate sync and sync panel Signed-off-by: linghaoSu --- .../applications-operation-panel.tsx | 135 ++++++++++++ .../applications-sync-panel.tsx | 199 +++++------------- .../applications-terminate-sync-panel.tsx | 156 +++++--------- .../application-selector.tsx | 29 ++- 4 files changed, 262 insertions(+), 257 deletions(-) create mode 100644 ui/src/app/applications/components/applications-operation-panel/applications-operation-panel.tsx diff --git a/ui/src/app/applications/components/applications-operation-panel/applications-operation-panel.tsx b/ui/src/app/applications/components/applications-operation-panel/applications-operation-panel.tsx new file mode 100644 index 0000000000000..565bcec4b7147 --- /dev/null +++ b/ui/src/app/applications/components/applications-operation-panel/applications-operation-panel.tsx @@ -0,0 +1,135 @@ +import {FormField, NotificationType, SlidingPanel} from 'argo-ui'; +import * as React from 'react'; +import {Form, FormApi} from 'react-form'; +import {ProgressPopup, Spinner} from '../../../shared/components'; +import {Consumer, ContextApis} from '../../../shared/context'; +import * as models from '../../../shared/models'; +import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options'; +import {ApplicationManualSyncFlags, ApplicationSyncOptions, SyncFlags} from '../application-sync-options/application-sync-options'; +import {ApplicationSelector} from '../../../shared/components'; + +interface Progress { + percentage: number; + title: string; +} + +interface OperationPanelProps { + title: string; + buttonTitle: string; + show: boolean; + apps: models.Application[]; + hide: () => void; + onSubmit: ( + apps: models.Application[], + syncFlags: SyncFlags, + syncOptions: string[], + retryStrategy: models.RetryStrategy, + updateProgress: (newProgress: Progress) => void, + ctx: ContextApis + ) => Promise; + additionalFormFields?: React.ReactNode; + showOptions?: boolean; + selectionOptions?: {all: boolean; syncing?: boolean; outOfSync?: boolean; none: boolean}; +} + +export const ApplicationsOperationPanel: React.FC = ({ + title, + buttonTitle, + show, + apps, + hide, + onSubmit, + additionalFormFields, + showOptions = true, + selectionOptions = {all: true, none: true} +}) => { + const [form, setForm] = React.useState(null); + const [progress, setProgress] = React.useState(null); + const [isPending, setPending] = React.useState(false); + + const validApps = React.useMemo(() => apps?.filter(app => app != null) || [], [apps]); + + const getSelectedApps = (params: any) => validApps.filter((_, i) => params['app/' + i]); + + const updateProgress = (newProgress: Progress) => { + const clampedPercentage = Math.min(100, Math.max(0, newProgress.percentage)); + setProgress({ + ...newProgress, + percentage: clampedPercentage + }); + }; + + const handleSubmit = async (params: any, ctx: ContextApis) => { + setPending(true); + const selectedApps = getSelectedApps(params); + + if (selectedApps.length === 0) { + ctx.notifications.show({content: `No apps selected`, type: NotificationType.Error}); + setPending(false); + return; + } + + try { + await onSubmit(selectedApps, params.syncFlags || {}, params.syncOptions || [], params.retryStrategy, updateProgress, ctx); + } catch (e) { + console.error(e); + } finally { + setPending(false); + } + }; + + return ( + + {ctx => ( + { + setProgress(null); + hide(); + }} + header={ +
+ {' '} + +
+ }> +
handleSubmit(params, ctx)} getApi={setForm}> + {formApi => ( +
+

{title}

+ {progress !== null && setProgress(null)} percentage={progress.percentage} title={progress.title} />} + {showOptions && ( + <> +
+ +
+
+ + { + formApi.setTouched('syncOptions', true); + formApi.setValue('syncOptions', opts); + }} + id='applications-operation-panel' + /> +
+ + + )} + {additionalFormFields} + +
+ )} +
+
+ )} +
+ ); +}; diff --git a/ui/src/app/applications/components/applications-sync-panel/applications-sync-panel.tsx b/ui/src/app/applications/components/applications-sync-panel/applications-sync-panel.tsx index 0c97b75eb0b70..6c7d6a38e320c 100644 --- a/ui/src/app/applications/components/applications-sync-panel/applications-sync-panel.tsx +++ b/ui/src/app/applications/components/applications-sync-panel/applications-sync-panel.tsx @@ -1,159 +1,66 @@ -import {ErrorNotification, FormField, NotificationType, SlidingPanel} from 'argo-ui'; import * as React from 'react'; -import {Form, FormApi} from 'react-form'; -import {ARGO_WARNING_COLOR, ProgressPopup, Spinner} from '../../../shared/components'; -import {Consumer, ContextApis} from '../../../shared/context'; import * as models from '../../../shared/models'; import {services} from '../../../shared/services'; -import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options'; -import {ApplicationManualSyncFlags, ApplicationSyncOptions, FORCE_WARNING, SyncFlags} from '../application-sync-options/application-sync-options'; -import {ApplicationSelector} from '../../../shared/components'; -import {confirmSyncingAppOfApps, getAppDefaultSource} from '../utils'; - -interface Progress { - percentage: number; - title: string; -} +import {ApplicationsOperationPanel} from '../applications-operation-panel/applications-operation-panel'; +import {getAppDefaultSource} from '../utils'; +import {SyncFlags} from '../application-sync-options/application-sync-options'; +import {ContextApis} from '../../../shared/context'; +import {ErrorNotification, NotificationType} from 'argo-ui'; export const ApplicationsSyncPanel = ({show, apps, hide}: {show: boolean; apps: models.Application[]; hide: () => void}) => { - const [form, setForm] = React.useState(null); - const [progress, setProgress] = React.useState(null); - const getSelectedApps = (params: any) => apps.filter((_, i) => params['app/' + i]); - const [isPending, setPending] = React.useState(false); - const syncHandler = (currentForm: FormApi, ctx: ContextApis, applications: models.Application[]) => { - const formValues = currentForm.getFormState().values; - const replaceChecked = formValues.syncOptions?.includes('Replace=true'); - const selectedApps = []; - const selectedAppOfApps: models.Application[] = []; - let containAppOfApps = false; + const onSubmit = async ( + selectedApps: models.Application[], + syncFlags: SyncFlags, + syncOptions: string[], + retryStrategy: models.RetryStrategy, + setProgress: (progress: {percentage: number; title: string}) => void, + ctx: ContextApis + ) => { + const force = syncFlags.Force || false; + const syncStrategy: models.SyncStrategy = syncFlags.ApplyOnly ? {apply: {force}} : {hook: {force}}; - for (const key in formValues) { - if (key.startsWith('app/') && formValues[key]) { - selectedApps.push(applications[parseInt(key.slice(key.lastIndexOf('/') + 1), 10)]); - } - } - - selectedApps.forEach(app => { - if (app.isAppOfAppsPattern) { - containAppOfApps = true; - selectedAppOfApps.push(app); - } - }); + setProgress({percentage: 0, title: 'Starting...'}); + let completed = 0; + const total = selectedApps.length; - if (replaceChecked && containAppOfApps) { - confirmSyncingAppOfApps(selectedAppOfApps, ctx, currentForm).then(confirmed => { - setPending(confirmed ? true : false); + for (const app of selectedApps) { + setProgress({ + percentage: completed / total, + title: `Syncing ${app.metadata.name} (${completed + 1}/${total})` }); - } else { - currentForm.submitForm(null); + + try { + await services.applications.sync( + app.metadata.name, + app.metadata.namespace, + getAppDefaultSource(app).targetRevision, + syncFlags.Prune || false, + syncFlags.DryRun || false, + syncStrategy, + null, + syncOptions, + retryStrategy + ); + completed++; + setProgress({ + percentage: completed / total, + title: `Completed ${completed}/${total} applications` + }); + } catch (e) { + setProgress({ + percentage: completed / total, + title: `Failed to sync ${app.metadata.name} (${completed}/${total})` + }); + ctx.notifications.show({ + content: , + type: NotificationType.Error + }); + } } + setProgress({percentage: 100, title: `Successfully synced ${completed} applications`}); }; - return ( - - {ctx => ( - hide()} - header={ -
- {' '} - -
- }> -
{ - setPending(true); - const selectedApps = getSelectedApps(params); - const syncFlags = {...params.syncFlags} as SyncFlags; - const force = syncFlags.Force || false; - if (force) { - const confirmed = await ctx.popup.confirm('Synchronize with force?', () => ( -
- {FORCE_WARNING} Are you sure you want to continue? -
- )); - if (!confirmed) { - setPending(false); - return; - } - } - if (selectedApps.length === 0) { - ctx.notifications.show({content: `No apps selected`, type: NotificationType.Error}); - setPending(false); - return; - } - - const syncStrategy: models.SyncStrategy = syncFlags.ApplyOnly || false ? {apply: {force}} : {hook: {force}}; - - setProgress({percentage: 0, title: 'Starting...'}); - let i = 0; - for (const app of selectedApps) { - await services.applications - .sync( - app.metadata.name, - app.metadata.namespace, - getAppDefaultSource(app).targetRevision, - syncFlags.Prune || false, - syncFlags.DryRun || false, - syncStrategy, - null, - params.syncOptions, - params.retryStrategy - ) - .catch(e => { - ctx.notifications.show({ - content: , - type: NotificationType.Error - }); - }) - .finally(() => { - setPending(false); - }); - i++; - setProgress({ - percentage: i / selectedApps.length, - title: `${i} of ${selectedApps.length} apps now syncing` - }); - } - setProgress({percentage: 100, title: 'Complete'}); - }} - getApi={setForm}> - {formApi => ( - -
-

Sync app(s)

- {progress !== null && setProgress(null)} percentage={progress.percentage} title={progress.title} />} -
- -
-
- - { - formApi.setTouched('syncOptions', true); - formApi.setValue('syncOptions', opts); - }} - id='applications-sync-panel' - /> -
- + const validApps = React.useMemo(() => apps?.filter(app => app != null) || [], [apps]); - -
-
- )} - -
- )} -
- ); + return ; }; diff --git a/ui/src/app/applications/components/applications-terminate-sync-panel/applications-terminate-sync-panel.tsx b/ui/src/app/applications/components/applications-terminate-sync-panel/applications-terminate-sync-panel.tsx index 4bd7b2432bf54..025064bbd7f6d 100644 --- a/ui/src/app/applications/components/applications-terminate-sync-panel/applications-terminate-sync-panel.tsx +++ b/ui/src/app/applications/components/applications-terminate-sync-panel/applications-terminate-sync-panel.tsx @@ -1,118 +1,66 @@ -import {ErrorNotification, NotificationType, SlidingPanel} from 'argo-ui'; import * as React from 'react'; -import {Form, FormApi} from 'react-form'; -import {ProgressPopup, Spinner} from '../../../shared/components'; -import {Consumer, ContextApis} from '../../../shared/context'; import * as models from '../../../shared/models'; import {services} from '../../../shared/services'; -import {ApplicationSelector} from '../../../shared/components'; -import {confirmSyncingAppOfApps} from '../utils'; - -interface Progress { - percentage: number; - title: string; -} +import {ApplicationsOperationPanel} from '../applications-operation-panel/applications-operation-panel'; +import {ErrorNotification, NotificationType} from 'argo-ui'; +import {SyncFlags} from '../application-sync-options/application-sync-options'; +import {ContextApis} from '../../../shared/context'; export const ApplicationsTerminateSyncPanel = ({show, apps, hide}: {show: boolean; apps: models.Application[]; hide: () => void}) => { - const [form, setForm] = React.useState(null); - const [progress, setProgress] = React.useState(null); - const getSelectedApps = (params: any) => apps.filter((_, i) => params['app/' + i]); - const [isPending, setPending] = React.useState(false); - const syncHandler = (currentForm: FormApi, ctx: ContextApis, applications: models.Application[]) => { - const formValues = currentForm.getFormState().values; - const replaceChecked = formValues.syncOptions?.includes('Replace=true'); - const selectedApps = []; - const selectedAppOfApps: models.Application[] = []; - let containAppOfApps = false; + const onSubmit = async ( + selectedApps: models.Application[], + _syncFlags: SyncFlags, + _syncOptions: string[], + _retryStrategy: models.RetryStrategy, + setProgress: (progress: {percentage: number; title: string}) => void, + ctx: ContextApis + ) => { + setProgress({percentage: 0, title: 'Starting...'}); + let completed = 0; + const total = selectedApps.length; - for (const key in formValues) { - if (key.startsWith('app/') && formValues[key]) { - selectedApps.push(applications[parseInt(key.slice(key.lastIndexOf('/') + 1), 10)]); - } - } + for (const app of selectedApps) { + setProgress({ + percentage: completed / total, + title: `Terminating sync for ${app.metadata.name} (${completed + 1}/${total})` + }); - selectedApps.forEach(app => { - if (app.isAppOfAppsPattern) { - containAppOfApps = true; - selectedAppOfApps.push(app); + try { + await services.applications.terminateOperation(app.metadata.name, app.metadata.namespace); + completed++; + setProgress({ + percentage: completed / total, + title: `Terminated ${completed}/${total} operations` + }); + } catch (e) { + setProgress({ + percentage: completed / total, + title: `Failed to terminate ${app.metadata.name} (${completed}/${total})` + }); + ctx.notifications.show({ + content: , + type: NotificationType.Error + }); + throw e; } - }); - - if (replaceChecked && containAppOfApps) { - confirmSyncingAppOfApps(selectedAppOfApps, ctx, currentForm).then(confirmed => { - setPending(confirmed ? true : false); - }); - } else { - currentForm.submitForm(null); } + setProgress({percentage: 100, title: `Successfully terminated ${completed} operations`}); }; - return ( - - {ctx => ( - hide()} - header={ -
- {' '} - -
- }> -
{ - setPending(true); - const selectedApps = getSelectedApps(params); - - if (selectedApps.length === 0) { - ctx.notifications.show({content: `No apps selected`, type: NotificationType.Error}); - setPending(false); - return; - } + const validApps = React.useMemo(() => { + return apps?.filter(app => app != null && app.status?.operationState?.phase === 'Running' && app.status.operationState.operation.sync != null) || []; + }, [apps]); - setProgress({percentage: 0, title: 'Starting...'}); - let i = 0; - for (const app of selectedApps) { - await services.applications - .terminateOperation(app.metadata.name, app.metadata.namespace) - .catch(e => { - ctx.notifications.show({ - content: , - type: NotificationType.Error - }); - }) - .finally(() => { - setPending(false); - }); - i++; - setProgress({ - percentage: i / selectedApps.length, - title: `${i} of ${selectedApps.length} apps now terminating sync` - }); - } - setProgress({percentage: 100, title: 'Complete'}); - }} - getApi={setForm}> - {formApi => ( - -
-

Terminate Sync app(s)

- {progress !== null && setProgress(null)} percentage={progress.percentage} title={progress.title} />} - - -
-
- )} - -
- )} -
+ return ( + ); }; diff --git a/ui/src/app/shared/components/application-selector/application-selector.tsx b/ui/src/app/shared/components/application-selector/application-selector.tsx index 327e475a34a15..dbe3a034f2d74 100644 --- a/ui/src/app/shared/components/application-selector/application-selector.tsx +++ b/ui/src/app/shared/components/application-selector/application-selector.tsx @@ -5,22 +5,37 @@ import * as models from '../../models'; import {appInstanceName, appQualifiedName, ComparisonStatusIcon, HealthStatusIcon, OperationPhaseIcon} from '../../../applications/components/utils'; import {AuthSettingsCtx} from '../../context'; -export const ApplicationSelector = ({apps, formApi, terminate}: {apps: models.Application[]; formApi: FormFunctionProps; terminate?: boolean}) => { +interface ApplicationSelectorProps { + apps: models.Application[]; + formApi: FormFunctionProps; + terminate?: boolean; + selectionOptions?: {all: boolean; syncing?: boolean; outOfSync?: boolean; none: boolean}; +} + +export const ApplicationSelector = ({ + apps, + formApi, + selectionOptions = { + outOfSync: true, + all: true, + none: true + } +}: ApplicationSelectorProps) => { const useAuthSettingsCtx = React.useContext(AuthSettingsCtx); return ( <>