diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 8d2fa7727d..a02755c9c7 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -36,25 +36,6 @@ Add the following entry to the head of your `packages/app/src/plugins.ts`: export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; ``` -Add the following to your `packages/app/src/apis.ts`: - -```ts -import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder'; - -// Inside the ApiRegistry builder function ... - -builder.add( - scaffolderApiRef, - new ScaffolderApi({ - apiOrigin: backendUrl, - basePath: '/scaffolder/v1', - }), -); -``` - -Where `backendUrl` is the `backend.baseUrl` from config, i.e. -`const backendUrl = config.getString('backend.baseUrl')`. - This is all that is needed for the frontend part of the Scaffolder plugin to work! diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 5bdeec3e10..250a59e775 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -19,9 +19,8 @@ import { createDevApp } from '@backstage/dev-utils'; import { discoveryApiRef } from '@backstage/core'; import { CatalogClient } from '@backstage/catalog-client'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { TemplateIndexPage, TemplatePage } from '../src/plugin'; -import { ScaffolderApi, scaffolderApiRef } from '../src'; -import { TaskPage } from '../src/components/TaskPage'; +import { TemplateIndexPage, TemplatePage, TaskPage } from '../src/plugin'; +import { ScaffolderClient, scaffolderApiRef } from '../src'; createDevApp() .registerApi({ @@ -32,7 +31,7 @@ createDevApp() .registerApi({ api: scaffolderApiRef, deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }), + factory: ({ discoveryApi }) => new ScaffolderClient({ discoveryApi }), }) .addPage({ path: '/create', diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 79ea075e3d..b332c37c28 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", "@backstage/config": "^0.1.2", + "@backstage/core": "^0.6.0", "@backstage/plugin-catalog-react": "^0.0.2", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -41,6 +41,7 @@ "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", + "clsx": "^1.1.1", "git-url-parse": "^11.4.4", "moment": "^2.26.0", "react": "^16.13.1", @@ -50,8 +51,8 @@ "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", "swr": "^0.3.0", - "zen-observable": "^0.8.15", - "use-immer": "^0.4.2" + "use-immer": "^0.4.2", + "zen-observable": "^0.8.15" }, "devDependencies": { "@backstage/cli": "^0.6.0", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 1d6bb2007b..c51bd4a8be 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -32,7 +32,27 @@ type LogEvent = { taskId: string; }; -export class ScaffolderApi { +export interface ScaffolderApi { + /** + * Executes the scaffolding of a component, given a template and its + * parameter values. + * + * @param templateName Template name for the scaffolder to use. New project is going to be created out of this template. + * @param values Parameters for the template, e.g. name, description + */ + scaffold(templateName: string, values: Record): Promise; + + getTask(taskId: string): Promise; + + streamLogs({ + taskId, + after, + }: { + taskId: string; + after?: number; + }): Observable; +} +export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; constructor(options: { discoveryApi: DiscoveryApi }) { @@ -46,7 +66,10 @@ export class ScaffolderApi { * @param templateName Template name for the scaffolder to use. New project is going to be created out of this template. * @param values Parameters for the template, e.g. name, description */ - async scaffold(templateName: string, values: Record) { + async scaffold( + templateName: string, + values: Record, + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v2/tasks`; const response = await fetch(url, { method: 'POST', @@ -62,7 +85,7 @@ export class ScaffolderApi { throw new Error(`Backend request failed, ${status} ${body.trim()}`); } - const { id } = await response.json(); + const { id } = (await response.json()) as { id: string }; return id; } diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx deleted file mode 100644 index 7ef6454588..0000000000 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { Button, Observable, Subscription, useApi } from '@backstage/core'; -import { - Button as Action, - Dialog, - DialogActions, - DialogContent, - DialogTitle, -} from '@material-ui/core'; - -export const JobStatusModal = ({ - task, - toCatalogLink, - open, - onModalClose, -}: Props) => { - const eventStream = useTaskEventStream(task?.id!); - - const renderTitle = () => { - switch (task?.status) { - case 'completed': - return 'Successfully created component'; - case 'failed': - return 'Failed to create component'; - default: - return 'Create component'; - } - }; - const onClose = useCallback(() => { - if (!task) { - return; - } - // Disallow closing modal if the job is in progress. - if (task.status !== 'processing') { - onModalClose(); - } - }, [task, onModalClose]); - - console.log(eventStream); - - return ( - - {renderTitle()} - - {task?.spec.steps - .filter(step => !!eventStream?.steps?.[step.id]) - .map(step => ( - - ))} - - {/* {job?.status && toCatalogLink && ( - - - - )} - {job?.status === 'FAILED' && ( - - Close - - )} */} - - ); -}; diff --git a/plugins/scaffolder/src/components/JobStatusModal/index.ts b/plugins/scaffolder/src/components/JobStatusModal/index.ts deleted file mode 100644 index 5598999fe3..0000000000 --- a/plugins/scaffolder/src/components/JobStatusModal/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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. - */ -export { JobStatusModal } from './JobStatusModal'; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 6debff5b0e..2f22cb9849 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -15,18 +15,102 @@ */ import { Page, Header, Lifecycle, Content } from '@backstage/core'; -import React from 'react'; -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; +import React, { useState, useEffect } from 'react'; +import { + makeStyles, + Theme, + createStyles, + withStyles, +} from '@material-ui/core/styles'; import Stepper from '@material-ui/core/Stepper'; import Step from '@material-ui/core/Step'; import StepLabel from '@material-ui/core/StepLabel'; import StepContent from '@material-ui/core/StepContent'; +import StepConnector from '@material-ui/core/StepConnector'; import Button from '@material-ui/core/Button'; import Paper from '@material-ui/core/Paper'; +import clsx from 'clsx'; +import Check from '@material-ui/icons/Check'; +import Cancel from '@material-ui/icons/Cancel'; import Typography from '@material-ui/core/Typography'; import { useParams } from 'react-router'; import { useTaskEventStream, TaskStream } from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; +import { StepButton, StepIconProps } from '@material-ui/core'; + +const QontoConnector = withStyles({ + active: { + '& $line': { + borderColor: '#784af4', + }, + }, + completed: { + '& $line': { + borderColor: '#784af4', + }, + }, + line: { + borderColor: '#eaeaf0', + borderTopWidth: 3, + borderRadius: 1, + }, +})(StepConnector); + +const useQontoStepIconStyles = makeStyles({ + root: { + color: '#eaeaf0', + display: 'flex', + height: 22, + alignItems: 'center', + }, + active: { + color: 'grey', + }, + error: { + color: 'red', + }, + circle: { + width: 8, + height: 8, + borderRadius: '50%', + backgroundColor: 'currentColor', + }, + completed: { + color: 'green', + zIndex: 1, + fontSize: 18, + }, +}); + +function QontoStepIcon(props: StepIconProps) { + const classes = useQontoStepIconStyles(); + const { active, completed, error } = props; + + const getComponent = () => { + if (error) { + return ; + } + + if (completed) { + return ; + } + + if (active) { + return
; + } + return undefined; + }; + return ( +
+ {getComponent()} +
+ ); +} const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -48,7 +132,9 @@ const useStyles = makeStyles((theme: Theme) => export const TaskStepper = ({ taskStream }: { taskStream: TaskStream }) => { const classes = useStyles(); - const [activeStep, setActiveStep] = React.useState(0); + const [activeStep, setActiveStep] = useState(0); + const [expandAll, setExpandAll] = useState(false); + const steps = taskStream?.task?.spec.steps ?? []; const handleNext = () => { @@ -59,32 +145,60 @@ export const TaskStepper = ({ taskStream }: { taskStream: TaskStream }) => { setActiveStep(prevActiveStep => prevActiveStep - 1); }; + const handleStep = (step: number) => { + setExpandAll(false); + setActiveStep(step); + }; + const handleReset = () => { setActiveStep(0); }; + useEffect(() => { + const activeIndex = Object.values(taskStream?.steps ?? {}).findIndex(step => + ['failed', 'processing'].includes(step.status), + ); + setActiveStep(activeIndex); + }, [taskStream]); + return (
- - {steps.map((step, index) => ( - - - {step.name} - - -
- -
-
-
- ))} + + + + + {steps.map((step, index) => { + const isCompleted = + taskStream.steps?.[step.id].status === 'completed'; + const isFailed = taskStream.steps?.[step.id].status === 'failed'; + return ( + + handleStep(index)}> + + {step.name} + + + + +
+ +
+
+
+ ); + })}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index d9a00d0dda..7239781b5c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -22,20 +22,19 @@ import { Lifecycle, Page, useApi, + useRouteRef, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useState } from 'react'; -import { generatePath, Navigate } from 'react-router'; +import { Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; -import { rootRoute } from '../../routes'; -import { ScaffolderTask } from '../../types'; -import { useTaskPolling } from '../hooks/useTaskPolling'; -import { JobStatusModal } from '../JobStatusModal'; +import { rootRoute, taskRoute } from '../../routes'; +import { useNavigate } from 'react-router'; import { MultistepJsonForm } from '../MultistepJsonForm'; const useTemplate = ( @@ -79,24 +78,24 @@ export const TemplatePage = () => { const catalogApi = useApi(catalogApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); - const [catalogLink, setCatalogLink] = useState(); + const navigate = useNavigate(); + const tasks = useRouteRef(taskRoute); const { template, loading } = useTemplate(templateName, catalogApi); const [formState, setFormState] = useState({}); - const [modalOpen, setModalOpen] = useState(false); const handleFormReset = () => setFormState({}); + const handleChange = useCallback( (e: IChangeEvent) => setFormState({ ...formState, ...e.formData }), [setFormState, formState], ); - const [task, setTask] = useState(undefined); + const [taskId, setTaskId] = useState(undefined); const handleCreate = async () => { try { const id = await scaffolderApi.scaffold(templateName, formState); - const returned = await scaffolderApi.getTask(id); - setTask(returned); - setModalOpen(true); + setTaskId(id); + navigate(tasks({ taskId: id })); } catch (e) { errorApi.post(e); } @@ -129,14 +128,6 @@ export const TemplatePage = () => { /> {loading && } - {task && ( - setModalOpen(false)} - /> - )} {template && ( new ScaffolderApi({ discoveryApi }), + factory: ({ discoveryApi }) => new ScaffolderClient({ discoveryApi }), }), ], register({ router }) { diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 09af4717aa..6b55f89ff1 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -28,4 +28,5 @@ export const templateRoute = createRouteRef({ export const taskRoute = createRouteRef({ path: '/scaffolder/task/:taskId', title: 'Task information', + params: ['taskId'], });