refactor scaffolder

This commit is contained in:
Johan Haals
2021-02-10 11:41:22 +01:00
parent 593251b69d
commit 36d0954886
11 changed files with 185 additions and 174 deletions
@@ -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!
+3 -4
View File
@@ -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',
+4 -3
View File
@@ -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",
+26 -3
View File
@@ -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<string, any>): Promise<string>;
getTask(taskId: string): Promise<ScaffolderTask>;
streamLogs({
taskId,
after,
}: {
taskId: string;
after?: number;
}): Observable<LogEvent>;
}
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<string, any>) {
async scaffold(
templateName: string,
values: Record<string, any>,
): Promise<string> {
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;
}
@@ -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 (
<Dialog open={open} onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">{renderTitle()}</DialogTitle>
<DialogContent>
{task?.spec.steps
.filter(step => !!eventStream?.steps?.[step.id])
.map(step => (
<JobStage
log={eventStream?.steps?.[step.id].log ?? []}
name={step.name}
key={step.name}
startedAt={eventStream?.steps?.[step.id].startedAt}
endedAt={eventStream?.steps?.[step.id].endedAt}
status={eventStream?.steps?.[step.id].status ?? []}
/>
))}
</DialogContent>
{/* {job?.status && toCatalogLink && (
<DialogActions>
<Button to={toCatalogLink}>View in catalog</Button>
</DialogActions>
)}
{job?.status === 'FAILED' && (
<DialogActions>
<Action onClick={onClose}>Close</Action>
</DialogActions>
)} */}
</Dialog>
);
};
@@ -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';
@@ -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 <Cancel className={classes.error} />;
}
if (completed) {
return <Check className={classes.completed} />;
}
if (active) {
return <div className={classes.circle} />;
}
return undefined;
};
return (
<div
className={clsx(classes.root, {
[classes.active]: active,
[classes.error]: error,
})}
>
{getComponent()}
</div>
);
}
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 (
<div className={classes.root}>
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map((step, index) => (
<Step key={String(index)}>
<StepLabel error={taskStream.steps?.[step.id]?.status === 'failed'}>
{step.name}
</StepLabel>
<StepContent>
<div style={{ height: '50vh' }}>
<LazyLog
extraLines={1}
text={
taskStream.steps?.[step.id]?.log.length
? taskStream.steps?.[step.id]?.log.join('\n')
: 'fetching...'
}
/>
</div>
</StepContent>
</Step>
))}
<Button variant="outlined" onClick={() => setExpandAll(true)}>
Expand All
</Button>
<Button variant="outlined">Retry</Button>
<Button variant="outlined">Raw Log</Button>
<Stepper activeStep={activeStep} orientation="vertical" nonLinear>
{steps.map((step, index) => {
const isCompleted =
taskStream.steps?.[step.id].status === 'completed';
const isFailed = taskStream.steps?.[step.id].status === 'failed';
return (
<Step key={String(index)} expanded={expandAll}>
<StepButton onClick={() => handleStep(index)}>
<StepLabel
StepIconProps={{ completed: isCompleted, error: isFailed }}
StepIconComponent={QontoStepIcon}
>
{step.name}
</StepLabel>
</StepButton>
<StepContent>
<div style={{ height: '50vh' }}>
<LazyLog
extraLines={1}
text={
taskStream.steps?.[step.id]?.log.length
? taskStream.steps?.[step.id]?.log.join('\n')
: 'fetching...'
}
/>
</div>
</StepContent>
</Step>
);
})}
</Stepper>
{activeStep === steps.length && (
<Paper square elevation={0} className={classes.resetContainer}>
@@ -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<string | undefined>();
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<ScaffolderTask | undefined>(undefined);
const [taskId, setTaskId] = useState<string | undefined>(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 = () => {
/>
<Content>
{loading && <LinearProgress data-testid="loading-progress" />}
{task && (
<JobStatusModal
task={task}
toCatalogLink={catalogLink}
open={modalOpen}
onModalClose={() => setModalOpen(false)}
/>
)}
{template && (
<InfoCard title={template.metadata.title} noPadding>
<MultistepJsonForm
+2 -1
View File
@@ -21,5 +21,6 @@ export {
TemplatePage,
TaskPage,
} from './plugin';
export { ScaffolderApi, scaffolderApiRef } from './api';
export type { ScaffolderApi } from './api';
export { ScaffolderClient, scaffolderApiRef } from './api';
export { rootRoute, templateRoute, taskRoute } from './routes';
+2 -2
View File
@@ -24,7 +24,7 @@ import { ScaffolderPage as ScaffolderPageComponent } from './components/Scaffold
import { TemplatePage as TemplatePageComponent } from './components/TemplatePage';
import { TaskPage as TaskPageComponent } from './components/TaskPage';
import { rootRoute, templateRoute, taskRoute } from './routes';
import { scaffolderApiRef, ScaffolderApi } from './api';
import { scaffolderApiRef, ScaffolderClient } from './api';
export const scaffolderPlugin = createPlugin({
id: 'scaffolder',
@@ -32,7 +32,7 @@ export const scaffolderPlugin = createPlugin({
createApiFactory({
api: scaffolderApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }),
factory: ({ discoveryApi }) => new ScaffolderClient({ discoveryApi }),
}),
],
register({ router }) {
+1
View File
@@ -28,4 +28,5 @@ export const templateRoute = createRouteRef({
export const taskRoute = createRouteRef({
path: '/scaffolder/task/:taskId',
title: 'Task information',
params: ['taskId'],
});