feat(scaffolder): mocked fe flow

This commit is contained in:
Ivan Shmidt
2020-06-29 11:42:17 +02:00
parent b3a2c4df84
commit 2fb51621c6
18 changed files with 669 additions and 29 deletions
@@ -11,3 +11,8 @@ spec:
processor: cookiecutter
type: website
path: '.'
parameters:
component_name:
title: Component name
type: string
description: Name of the component
+3 -1
View File
@@ -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",
+52
View File
@@ -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,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;
+2 -1
View File
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { plugin, rootRoute } from './plugin';
export { plugin } from './plugin';
export { rootRoute, createTemplateRoute } from './routes';
+5 -8
View File
@@ -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);
},
});
+12
View File
@@ -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',
});