diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 2618d9ef06..c3eb6f1f5d 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -74,6 +74,7 @@ "@backstage/plugin-techdocs-common": "workspace:^", "@backstage/plugin-techdocs-react": "workspace:^", "@backstage/types": "workspace:^", + "@backstage/ui": "workspace:^", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.1.0", "@codemirror/view": "^6.0.0", @@ -81,6 +82,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@react-hookz/web": "^24.0.0", + "@remixicon/react": "^4.6.0", "@rjsf/core": "5.24.13", "@rjsf/material-ui": "5.24.13", "@rjsf/utils": "5.24.13", diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx index 16e4bc9158..f40aeb545b 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx @@ -58,6 +58,7 @@ import { scaffolderTranslationRef } from '../../translation'; import { entityPresentationApiRef } from '@backstage/plugin-catalog-react'; import { default as reactUseAsync } from 'react-use/esm/useAsync'; import { stringifyEntityRef } from '@backstage/catalog-model'; +import { OngoingTaskContextMenu } from './OngoingTaskContextMenu'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -132,9 +133,119 @@ export function OngoingTaskBody(props: { output?: ScaffolderTaskOutput; }>; }) { + const { taskId } = useParams(); + const taskStream = useTaskEventStream(taskId!); + const entityPresentationApi = useApi(entityPresentationApiRef); + const { t } = useTranslationRef(scaffolderTranslationRef); + + const [logsVisible, setLogVisibleState] = useState(false); + const [buttonBarVisible, setButtonBarVisibleState] = useState(true); + + const { allowed: canCancelTask } = usePermission({ + permission: taskCancelPermission, + resourceRef: taskId, + }); + + const { allowed: canReadTask } = usePermission({ + permission: taskReadPermission, + resourceRef: taskId, + }); + + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + }); + + const isRetryableTask = + taskStream.task?.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy === + 'startOver'; + + const canRetry = canReadTask && canCreateTask && isRetryableTask; + + const cancelEnabled = !(taskStream.cancelled || taskStream.completed); + const isCancelButtonDisabled = !cancelEnabled || !canCancelTask; + + const { value: presentation } = reactUseAsync(async () => { + const templateEntityRef = taskStream.task?.spec.templateInfo?.entityRef; + if (!templateEntityRef) { + return undefined; + } + return entityPresentationApi.forEntity(templateEntityRef).promise; + }, [entityPresentationApi, taskStream.task?.spec.templateInfo?.entityRef]); + + const templateRouteRef = useRouteRef(selectedTemplateRouteRef); + const navigate = useNavigate(); + const analytics = useAnalytics(); + const scaffolderApi = useApi(scaffolderApiRef); + + const startOver = useCallback(() => { + const { namespace, name } = + taskStream.task?.spec.templateInfo?.entity?.metadata ?? {}; + + const formData = taskStream.task?.spec.parameters ?? {}; + + if (!namespace || !name) { + return; + } + + analytics.captureEvent('click', `Task has been started over`); + + navigate({ + pathname: templateRouteRef({ + namespace, + templateName: name, + }), + search: `?${qs.stringify({ formData: JSON.stringify(formData) })}`, + }); + }, [ + analytics, + navigate, + taskStream.task?.spec.parameters, + taskStream.task?.spec.templateInfo?.entity?.metadata, + templateRouteRef, + ]); + + const [, { execute: triggerRetry }] = useAsync(async () => { + if (taskId) { + analytics.captureEvent('retried', 'Template has been retried'); + await scaffolderApi.retry?.(taskId); + } + }); + + const [{ status: cancelStatus }, { execute: triggerCancel }] = useAsync( + async () => { + if (taskId) { + analytics.captureEvent('cancelled', 'Template has been cancelled'); + await scaffolderApi.cancelTask(taskId); + } + }, + ); + return ( - + + ); } diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx new file mode 100644 index 0000000000..9539838590 --- /dev/null +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Header, MenuTrigger, ButtonIcon, Menu, MenuItem } from '@backstage/ui'; +import { + RiMore2Line, + RiFileListLine, + RiAddCircleLine, + RiRepeatLine, + RiReplay10Line, + RiCloseCircleLine, +} from '@remixicon/react'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { + taskReadPermission, + taskCreatePermission, +} from '@backstage/plugin-scaffolder-common/alpha'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { scaffolderTranslationRef } from '../../translation'; + +type OngoingTaskContextMenuProps = { + title: string; + cancelEnabled?: boolean; + canRetry: boolean; + isRetryableTask: boolean; + logsVisible?: boolean; + buttonBarVisible?: boolean; + onRetry?: () => void; + onStartOver?: () => void; + onToggleLogs?: (state: boolean) => void; + onToggleButtonBar?: (state: boolean) => void; + taskId?: string; + isCancelButtonDisabled: boolean; + onCancel: () => void; +}; + +export function OngoingTaskContextMenu(props: OngoingTaskContextMenuProps) { + const { + title, + cancelEnabled, + canRetry, + isRetryableTask, + logsVisible, + buttonBarVisible, + onRetry, + onStartOver, + onToggleLogs, + onToggleButtonBar, + taskId, + } = props; + const { t } = useTranslationRef(scaffolderTranslationRef); + + const { allowed: canReadTask } = usePermission({ + permission: taskReadPermission, + resourceRef: taskId, + }); + + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + }); + + const canStartOver = canReadTask && canCreateTask; + + return ( +
+ } + aria-label="More options" + /> + + onToggleLogs?.(!logsVisible)} + iconStart={} + > + {logsVisible + ? t('ongoingTask.contextMenu.hideLogs') + : t('ongoingTask.contextMenu.showLogs')} + + onToggleButtonBar?.(!buttonBarVisible)} + iconStart={} + > + {buttonBarVisible + ? t('ongoingTask.contextMenu.hideButtonBar') + : t('ongoingTask.contextMenu.showButtonBar')} + + } + > + {t('ongoingTask.contextMenu.startOver')} + + {isRetryableTask && ( + } + > + {t('ongoingTask.contextMenu.retry')} + + )} + } + color="danger" + > + {t('ongoingTask.contextMenu.cancel')} + + + + } + /> + ); +} diff --git a/yarn.lock b/yarn.lock index b9930034bb..835ce66b1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7031,6 +7031,7 @@ __metadata: "@backstage/plugin-techdocs-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" + "@backstage/ui": "workspace:^" "@codemirror/language": "npm:^6.0.0" "@codemirror/legacy-modes": "npm:^6.1.0" "@codemirror/view": "npm:^6.0.0" @@ -7038,6 +7039,7 @@ __metadata: "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:4.0.0-alpha.61" "@react-hookz/web": "npm:^24.0.0" + "@remixicon/react": "npm:^4.6.0" "@rjsf/core": "npm:5.24.13" "@rjsf/material-ui": "npm:5.24.13" "@rjsf/utils": "npm:5.24.13"