refactor scaffolder
This commit is contained in:
@@ -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!
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -28,4 +28,5 @@ export const templateRoute = createRouteRef({
|
||||
export const taskRoute = createRouteRef({
|
||||
path: '/scaffolder/task/:taskId',
|
||||
title: 'Task information',
|
||||
params: ['taskId'],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user