feat(scaffolder): mocked fe flow
This commit is contained in:
@@ -11,3 +11,8 @@ spec:
|
||||
processor: cookiecutter
|
||||
type: website
|
||||
path: '.'
|
||||
parameters:
|
||||
component_name:
|
||||
title: Component name
|
||||
type: string
|
||||
description: Name of the component
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.12",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
|
||||
"@backstage/core": "^0.1.1-alpha.12",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
|
||||
"@backstage/theme": "^0.1.1-alpha.12",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@rjsf/core": "^2.1.0",
|
||||
"@rjsf/material-ui": "^2.1.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "6.0.0-alpha.5",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 { createApiRef } from '@backstage/core';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
|
||||
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
|
||||
id: 'plugin.scaffolder.service',
|
||||
description: 'Used to make requests towards the scaffolder backend',
|
||||
});
|
||||
|
||||
export class ScaffolderApi {
|
||||
private apiOrigin: string;
|
||||
private basePath: string;
|
||||
|
||||
constructor({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
}: {
|
||||
apiOrigin: string;
|
||||
basePath: string;
|
||||
}) {
|
||||
this.apiOrigin = apiOrigin;
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
async scaffold(
|
||||
template: TemplateEntityV1alpha1,
|
||||
values: Record<string, any>,
|
||||
) {
|
||||
const url = `${this.apiOrigin}${this.basePath}/jobs`;
|
||||
const jobId = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template, values }),
|
||||
}).then(x => x.json());
|
||||
|
||||
return jobId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import useStaleWhileRevalidate from 'swr';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { LinearProgress, Button } from '@material-ui/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
useApi,
|
||||
SimpleStepper,
|
||||
SimpleStepperStep,
|
||||
Page,
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
Lifecycle,
|
||||
} from '@backstage/core';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { withTheme, IChangeEvent } from '@rjsf/core';
|
||||
import { Theme as MuiTheme } from '@rjsf/material-ui';
|
||||
import { JobStatusModal } from '../JobStatusModal';
|
||||
import { scaffolderApiRef } from '../../api';
|
||||
|
||||
const Form = withTheme(MuiTheme);
|
||||
|
||||
export const CreatePage = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const { templateName } = useParams();
|
||||
const {
|
||||
data: [template] = [] as TemplateEntityV1alpha1[],
|
||||
isValidating,
|
||||
} = useStaleWhileRevalidate(
|
||||
`templates/${templateName}`,
|
||||
async () =>
|
||||
(catalogApi.getEntities({
|
||||
kind: 'Template',
|
||||
'metadata.name': templateName,
|
||||
}) as any) as Promise<TemplateEntityV1alpha1[]>,
|
||||
);
|
||||
const [formState, setFormState] = useState({});
|
||||
|
||||
const handleChange = (e: IChangeEvent) =>
|
||||
setFormState({ ...formState, ...e.formData });
|
||||
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const handleClose = () => setJobId(null);
|
||||
if (!template && isValidating) return <LinearProgress />;
|
||||
if (!template || !template?.spec?.parameters) return null;
|
||||
|
||||
const handleCreate = async () => {
|
||||
const job = await scaffolderApi.scaffold(template, formState);
|
||||
setJobId(job);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header
|
||||
pageTitleOverride="Create a new component"
|
||||
title={
|
||||
<>
|
||||
Create a new component <Lifecycle alpha shorthand />
|
||||
</>
|
||||
}
|
||||
subtitle="Create new software components using standard templates"
|
||||
/>
|
||||
<Content>
|
||||
<ContentHeader
|
||||
title={template.metadata.title as string}
|
||||
></ContentHeader>
|
||||
{jobId && <JobStatusModal jobId={jobId} onClose={handleClose} />}
|
||||
<SimpleStepper
|
||||
onStepChange={(_prevStep, nextStep) => {
|
||||
if (nextStep === 2) {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SimpleStepperStep title="Configure your component">
|
||||
<Form
|
||||
formData={formState}
|
||||
onChange={handleChange}
|
||||
schema={{
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
properties: template?.spec?.parameters,
|
||||
}}
|
||||
>
|
||||
<Button hidden />
|
||||
</Form>
|
||||
</SimpleStepperStep>
|
||||
<SimpleStepperStep title="Choose repository">
|
||||
<Form
|
||||
formData={formState}
|
||||
onChange={handleChange}
|
||||
schema={{
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
properties: {
|
||||
repo: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Path to the repo where to upload created component',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button hidden />
|
||||
</Form>
|
||||
</SimpleStepperStep>
|
||||
</SimpleStepper>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CreatePage } from './CreatePage';
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 React, { useEffect, useState, Suspense } from 'react';
|
||||
import {
|
||||
ExpansionPanel,
|
||||
ExpansionPanelSummary,
|
||||
Typography,
|
||||
ExpansionPanelDetails,
|
||||
LinearProgress,
|
||||
} from '@material-ui/core';
|
||||
import moment from 'moment';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Job } from './types';
|
||||
|
||||
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
|
||||
moment.relativeTimeThreshold('ss', 0);
|
||||
const useStyles = makeStyles(theme => ({
|
||||
expansionPanelDetails: {
|
||||
padding: 0,
|
||||
},
|
||||
button: {
|
||||
order: -1,
|
||||
marginRight: 0,
|
||||
marginLeft: '-20px',
|
||||
},
|
||||
neutral: {},
|
||||
failed: {
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
pointerEvents: 'none',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`,
|
||||
},
|
||||
},
|
||||
running: {
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
pointerEvents: 'none',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`,
|
||||
},
|
||||
},
|
||||
cardContent: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
success: {
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
pointerEvents: 'none',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
className?: string;
|
||||
log: string[];
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
status?: Job['status'];
|
||||
};
|
||||
export const JobStage = ({
|
||||
finishedAt,
|
||||
startedAt,
|
||||
name,
|
||||
log,
|
||||
status,
|
||||
}: Props) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (status === 'FAILED') setExpanded(true);
|
||||
}, [status === 'FAILED', setExpanded]);
|
||||
|
||||
const timeElapsed = moment
|
||||
.duration(moment(finishedAt ?? moment()).diff(moment(startedAt)))
|
||||
.humanize();
|
||||
|
||||
return (
|
||||
<ExpansionPanel
|
||||
TransitionProps={{ unmountOnExit: true }}
|
||||
className={
|
||||
status === 'COMPLETE'
|
||||
? classes.success
|
||||
: status === 'FAILED'
|
||||
? classes.failed
|
||||
: classes.neutral
|
||||
}
|
||||
expanded={expanded}
|
||||
onChange={(_, newState) => setExpanded(newState)}
|
||||
>
|
||||
<ExpansionPanelSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls={`panel-${name}-content`}
|
||||
id={`panel-${name}-header`}
|
||||
IconButtonProps={{
|
||||
className: classes.button,
|
||||
}}
|
||||
>
|
||||
<Typography variant="button">
|
||||
{name} ({timeElapsed})
|
||||
</Typography>
|
||||
</ExpansionPanelSummary>
|
||||
<ExpansionPanelDetails className={classes.expansionPanelDetails}>
|
||||
{log.length === 0 ? (
|
||||
'Nothing here...'
|
||||
) : (
|
||||
<Suspense fallback={<LinearProgress />}>
|
||||
<div style={{ height: '20vh', width: '100%' }}>
|
||||
<LazyLog text={log.join('\n')} extraLines={1} enableSearch />
|
||||
</div>
|
||||
</Suspense>
|
||||
)}
|
||||
</ExpansionPanelDetails>
|
||||
</ExpansionPanel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
LinearProgress,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
} from '@material-ui/core';
|
||||
import { JobStage } from './JobStage';
|
||||
import { useJob } from './jobMocks';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
export const JobStatusModal = ({ onClose, jobId }: Props) => {
|
||||
const job = useJob(jobId);
|
||||
return (
|
||||
<Dialog open onClose={onClose} fullWidth>
|
||||
<DialogTitle id="responsive-dialog-title">
|
||||
Creating component...
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{!job ? (
|
||||
<LinearProgress />
|
||||
) : (
|
||||
(job?.stages ?? []).map(step => (
|
||||
<JobStage
|
||||
log={step.log}
|
||||
name={step.name}
|
||||
key={step.name}
|
||||
startedAt={step.startedAt}
|
||||
finishedAt={step.finishedAt}
|
||||
status={step.status}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { JobStatusModal } from './JobStatusModal';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { Job } from './types';
|
||||
|
||||
function* emulatePoll() {
|
||||
const now = () => new Date().toString();
|
||||
const job: Job = {
|
||||
id: '132536-42362-4253532',
|
||||
metadata: { entity: {}, values: {} },
|
||||
status: 'STARTED',
|
||||
stages: [
|
||||
{
|
||||
name: 'created',
|
||||
startedAt: now(),
|
||||
log: [
|
||||
'Job id #rw-tstywe-tdsy was successfully created and placed in the queue',
|
||||
],
|
||||
status: 'STARTED',
|
||||
},
|
||||
],
|
||||
};
|
||||
let newTime = now();
|
||||
job.stages[0].finishedAt = newTime;
|
||||
job.stages.push({
|
||||
startedAt: newTime,
|
||||
name: 'preparing',
|
||||
log: ['preparing blahblah', 'some other stuuff'],
|
||||
status: 'COMPLETE',
|
||||
});
|
||||
yield job;
|
||||
|
||||
newTime = now();
|
||||
job.stages[1].finishedAt = newTime;
|
||||
job.stages.push({
|
||||
startedAt: newTime,
|
||||
name: 'templating',
|
||||
log: ['templating blahblah', 'some other stuuff'],
|
||||
status: 'COMPLETE',
|
||||
});
|
||||
yield job;
|
||||
|
||||
newTime = now();
|
||||
job.stages[2].finishedAt = newTime;
|
||||
job.stages.push({
|
||||
startedAt: newTime,
|
||||
name: 'pushing',
|
||||
log: ['pushing blahblah', 'some other stuuff'],
|
||||
status: 'STARTED',
|
||||
});
|
||||
yield job;
|
||||
yield job;
|
||||
job.stages[3].status = 'FAILED';
|
||||
job.stages[3].log.push('ERROR OCCURED');
|
||||
|
||||
while (true) yield job;
|
||||
}
|
||||
|
||||
export const useJob = (jobId: string | null) => {
|
||||
const apiMock = useMemo(() => emulatePoll(), [jobId]);
|
||||
const [job, setJob] = useState<Job | void>(undefined);
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
const nextJobState = apiMock.next().value as Job;
|
||||
setJob({ ...nextJobState });
|
||||
const intervalId = setInterval(() => {
|
||||
const nextJobState = apiMock.next().value as Job;
|
||||
|
||||
if (nextJobState?.status === 'FAILED') {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
setJob({ ...nextJobState });
|
||||
}, 3000);
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [jobId, setJob]);
|
||||
return job;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Writable } from 'stream';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
metadata: {
|
||||
entity: any;
|
||||
values: any;
|
||||
};
|
||||
status: 'PENDING' | 'STARTED' | 'COMPLETE' | 'FAILED';
|
||||
stages: Stage[];
|
||||
logStream?: Writable;
|
||||
logger?: Logger;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type Stage = {
|
||||
name: string;
|
||||
log: string[];
|
||||
status: 'PENDING' | 'STARTED' | 'COMPLETE' | 'FAILED';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
};
|
||||
+13
-5
@@ -13,8 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { FC } from 'react';
|
||||
import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Button } from '@backstage/core';
|
||||
import { Card, Chip, Typography, makeStyles } from '@material-ui/core';
|
||||
import { createTemplateRoute } from '../../routes';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
header: {
|
||||
@@ -42,14 +45,17 @@ type TemplateCardProps = {
|
||||
tags: string[];
|
||||
title: string;
|
||||
type: string;
|
||||
name: string;
|
||||
};
|
||||
const TemplateCard: FC<TemplateCardProps> = ({
|
||||
const TemplateCard = ({
|
||||
description,
|
||||
tags,
|
||||
title,
|
||||
type,
|
||||
}) => {
|
||||
name,
|
||||
}: TemplateCardProps) => {
|
||||
const classes = useStyles();
|
||||
const href = generatePath(createTemplateRoute.path, { templateName: name });
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -65,7 +71,9 @@ const TemplateCard: FC<TemplateCardProps> = ({
|
||||
{description}
|
||||
</Typography>
|
||||
<div className={classes.footer}>
|
||||
<Button color="primary">Choose</Button>
|
||||
<Button color="primary" variant="contained" to={href}>
|
||||
Choose
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -35,11 +35,11 @@ import {
|
||||
LinearProgress,
|
||||
} from '@material-ui/core';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import TemplateCard from '../TemplateCard';
|
||||
import TemplateCard from './TemplateCard';
|
||||
import useStaleWhileRevalidate from 'swr';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
|
||||
const ScaffolderPage: React.FC<{}> = () => {
|
||||
export const ScaffolderPage: React.FC<{}> = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
@@ -98,6 +98,7 @@ const ScaffolderPage: React.FC<{}> = () => {
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TemplateCard
|
||||
key={template.metadata.uid}
|
||||
name={template.metadata.name}
|
||||
title={`${
|
||||
(template.metadata.title || template.metadata.name) ?? ''
|
||||
}`}
|
||||
@@ -113,5 +114,3 @@ const ScaffolderPage: React.FC<{}> = () => {
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScaffolderPage;
|
||||
|
||||
@@ -14,4 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin, rootRoute } from './plugin';
|
||||
export { plugin } from './plugin';
|
||||
export { rootRoute, createTemplateRoute } from './routes';
|
||||
|
||||
@@ -14,18 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin, createRouteRef } from '@backstage/core';
|
||||
import ScaffolderPage from './components/ScaffolderPage';
|
||||
|
||||
export const rootRoute = createRouteRef({
|
||||
icon: () => null,
|
||||
path: '/create',
|
||||
title: 'Create entity',
|
||||
});
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import { ScaffolderPage } from './components/ScaffolderPage';
|
||||
import { CreatePage } from './components/CreatePage';
|
||||
import { rootRoute, createTemplateRoute } from './routes';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'scaffolder',
|
||||
register({ router }) {
|
||||
router.addRoute(rootRoute, ScaffolderPage);
|
||||
router.addRoute(createTemplateRoute, CreatePage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRouteRef } from '@backstage/core';
|
||||
|
||||
export const rootRoute = createRouteRef({
|
||||
icon: () => null,
|
||||
path: '/create',
|
||||
title: 'Create new entity',
|
||||
});
|
||||
export const createTemplateRoute = createRouteRef({
|
||||
icon: () => null,
|
||||
path: '/create/:templateName',
|
||||
title: 'Entity creation',
|
||||
});
|
||||
Reference in New Issue
Block a user