Merge pull request #3388 from backstage/feat/fix-templates-for-scaffolding
fix: Sample Templates are broken with latest release
This commit is contained in:
@@ -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
|
||||
|
||||
+20
-33
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user