Merge pull request #3388 from backstage/feat/fix-templates-for-scaffolding

fix: Sample Templates are broken with latest release
This commit is contained in:
Ben Lambert
2020-11-23 10:08:20 +01:00
committed by GitHub
9 changed files with 103 additions and 99 deletions
@@ -55,6 +55,10 @@ contains more information about the required fields.
Once we have a `template.yaml` ready, we can then add it to the service catalog
for use by the scaffolder.
_NOTE_: When the `publish` step is completed, it is currently assumed by the
scaffolder that the final repository should contain a `catalog-info.yaml` in
order to register this with the Catalog in Backstage.
Currently the catalog supports loading definitions from GitHub + Local Files. To
load from other places, not only will there need to be another preparer, but the
support to load the location will also need to be added to the Catalog.
@@ -69,7 +69,7 @@ export class CreateReactAppTemplater implements TemplaterBase {
);
await fs.promises.writeFile(
`${finalDir}/component-info.yaml`,
`${finalDir}/catalog-info.yaml`,
yaml.stringify(componentInfo),
);
@@ -13,43 +13,51 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Button } from '@backstage/core';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import {
Button as Action,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
LinearProgress,
} from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import { generatePath } from 'react-router-dom';
import React, { useCallback, useState } from 'react';
import { Job } from '../../types';
import { JobStage } from '../JobStage/JobStage';
import { useJobPolling } from './useJobPolling';
type Props = {
onComplete: (job: Job) => void;
jobId: string;
entity: TemplateEntityV1alpha1 | null;
job: Job | null;
toCatalogLink?: string;
};
export const JobStatusModal = ({ jobId, onComplete, entity }: Props) => {
const job = useJobPolling(jobId);
const [dialogTitle, setDialogTitle] = useState('Creating component...');
export const JobStatusModal = ({ job, toCatalogLink }: Props) => {
const [isOpen, setOpen] = useState(true);
useEffect(() => {
if (job?.status === 'COMPLETED') {
setDialogTitle('Successfully created component');
onComplete(job);
} else if (job?.status === 'FAILED')
setDialogTitle('Failed to create component');
}, [job, onComplete, setDialogTitle]);
const renderTitle = () => {
switch (job?.status) {
case 'COMPLETED':
return 'Successfully created component';
case 'FAILED':
return 'Failed to create component';
default:
return 'Create component';
}
};
const onClose = useCallback(() => {
if (!job) {
return;
}
if (job.status === 'COMPLETED' || job.status === 'FAILED') {
setOpen(false);
}
}, [job]);
return (
<Dialog open fullWidth>
<DialogTitle id="responsive-dialog-title">{dialogTitle}</DialogTitle>
<Dialog open={isOpen} onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">{renderTitle()}</DialogTitle>
<DialogContent>
{!job ? (
<LinearProgress />
@@ -66,16 +74,14 @@ export const JobStatusModal = ({ jobId, onComplete, entity }: Props) => {
))
)}
</DialogContent>
{entity && (
{job?.status && toCatalogLink && (
<DialogActions>
<Button
to={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
>
View in catalog
</Button>
<Button to={toCatalogLink}>View in catalog</Button>
</DialogActions>
)}
{job?.status === 'FAILED' && (
<DialogActions>
<Action onClick={() => setOpen(false)}>Close</Action>
</DialogActions>
)}
</Dialog>
@@ -23,18 +23,22 @@ import {
Page,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import {
catalogApiRef,
entityRoute,
entityRouteParams,
} from '@backstage/plugin-catalog';
import { LinearProgress } from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import React, { useState, useCallback } from 'react';
import { Navigate } from 'react-router';
import { generatePath, Navigate } from 'react-router';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRoute } from '../../routes';
import { Job } from '../../types';
import { JobStatusModal } from '../JobStatusModal';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { useJobPolling } from '../hooks/useJobPolling';
const useTemplate = (
templateName: string,
@@ -81,10 +85,10 @@ export const TemplatePage = () => {
const catalogApi = useApi(catalogApiRef);
const scaffolderApi = useApi(scaffolderApiRef);
const { templateName } = useParams();
const [catalogLink, setCatalogLink] = useState<string | undefined>();
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 }),
@@ -92,35 +96,42 @@ export const TemplatePage = () => {
);
const [jobId, setJobId] = useState<string | null>(null);
const handleCreate = async () => {
try {
const job = await scaffolderApi.scaffold(templateName, formState);
setJobId(job);
} catch (e) {
errorApi.post(e);
}
};
const [entity, setEntity] = React.useState<TemplateEntityV1alpha1 | null>(
null,
);
const handleCreateComplete = async (job: Job) => {
const job = useJobPolling(jobId, async job => {
if (!job.metadata.catalogInfoUrl) {
errorApi.post(
new Error(
`Failed to find catalog-info.yaml file in ${job.metadata.remoteUrl}.`,
),
new Error(`No catalogInfoUrl returned from the scaffolder`),
);
return;
}
const {
entities: [createdEntity],
} = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl });
try {
const {
entities: [createdEntity],
} = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl });
setEntity((createdEntity as any) as TemplateEntityV1alpha1);
const resolvedPath = generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(createdEntity),
);
setCatalogLink(resolvedPath);
} catch (ex) {
errorApi.post(
new Error(
`Something went wrong trying to add the new 'catalog-info.yaml' to the catalog`,
),
);
}
});
const handleCreate = async () => {
try {
const jobId = await scaffolderApi.scaffold(templateName, formState);
setJobId(jobId);
setModalOpen(true);
} catch (e) {
errorApi.post(e);
}
};
if (!loading && !template) {
@@ -150,13 +161,7 @@ export const TemplatePage = () => {
/>
<Content>
{loading && <LinearProgress data-testid="loading-progress" />}
{jobId && (
<JobStatusModal
onComplete={handleCreateComplete}
jobId={jobId}
entity={entity}
/>
)}
{modalOpen && <JobStatusModal job={job} toCatalogLink={catalogLink} />}
{template && (
<InfoCard title={template.metadata.title} noPadding>
<MultistepJsonForm
@@ -13,50 +13,37 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Job } from '../../types';
import { useApi } from '@backstage/core';
import { scaffolderApiRef } from '../../api';
import { useInterval } from 'react-use';
const DEFAULT_POLLING_INTERVAL = 1000;
const poll = (thunk: () => Promise<void>, ms: number) => {
let shouldStop = false;
(async () => {
while (!shouldStop) {
await thunk();
await new Promise(res => setTimeout(res, ms));
}
})();
return () => {
shouldStop = true;
};
};
export const useJobPolling = (
jobId: string | null,
onFinish?: (j: Job) => void,
pollingInterval = DEFAULT_POLLING_INTERVAL,
) => {
const scaffolderApi = useApi(scaffolderApiRef);
const [job, setJob] = useState<Job | null>(null);
useEffect(() => {
if (!jobId) return () => {};
const stopPolling = poll(async () => {
const nextJobState = await scaffolderApi.getJob(jobId);
if (
nextJobState.status === 'FAILED' ||
nextJobState.status === 'COMPLETED'
) {
stopPolling();
const [currentJob, setCurrentJob] = useState<Job | null>(null);
const shouldBeRunningInterval =
jobId &&
currentJob?.status !== 'COMPLETED' &&
currentJob?.status !== 'FAILED';
useInterval(
async () => {
if (jobId) {
const job = await scaffolderApi.getJob(jobId);
if (job?.status === 'COMPLETED' || job?.status === 'FAILED') {
onFinish?.(job);
}
setCurrentJob(job);
}
setJob(nextJobState);
}, pollingInterval);
return () => {
stopPolling();
};
}, [jobId, setJob, scaffolderApi, pollingInterval]);
},
shouldBeRunningInterval ? pollingInterval : null,
);
return job;
return currentJob;
};
+4 -2
View File
@@ -13,6 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
export type Job = {
id: string;
metadata: {
@@ -21,7 +23,7 @@ export type Job = {
remoteUrl?: string;
catalogInfoUrl?: string;
};
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
status: JobStatus;
stages: Stage[];
error?: Error;
};
@@ -29,7 +31,7 @@ export type Job = {
export type Stage = {
name: string;
log: string[];
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
status: JobStatus;
startedAt: string;
endedAt?: string;
};