From 9ef2e68b3399f3f331f4a66decd0e726480f0820 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 8 Feb 2021 14:08:13 +0100 Subject: [PATCH 01/58] Add log metadata and getTask method --- .../src/scaffolder/tasks/DatabaseTaskStore.ts | 2 +- .../tasks/StorageTaskBroker.test.ts | 10 +-- .../src/scaffolder/tasks/StorageTaskBroker.ts | 10 ++- .../src/scaffolder/tasks/TaskWorker.ts | 89 ++++++++++--------- .../src/scaffolder/tasks/types.ts | 3 +- 5 files changed, 61 insertions(+), 53 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts index 5717f3c8c1..4190a5d58f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -64,7 +64,7 @@ export class DatabaseTaskStore implements TaskStore { constructor(private readonly db: Knex) {} - async get(taskId: string): Promise { + async getTask(taskId: string): Promise { const [result] = await this.db('tasks') .where({ id: taskId }) .select(); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts index c45fa46335..ffb8de6e3d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts @@ -83,7 +83,7 @@ describe('StorageTaskBroker', () => { const dispatchResult = await broker.dispatch({ steps: [] }); const task = await broker.claim(); await task.complete('completed'); - const taskRow = await storage.get(dispatchResult.taskId); + const taskRow = await storage.getTask(dispatchResult.taskId); expect(taskRow.status).toBe('completed'); }, 10000); @@ -92,7 +92,7 @@ describe('StorageTaskBroker', () => { const dispatchResult = await broker.dispatch({ steps: [] }); const task = await broker.claim(); await task.complete('failed'); - const taskRow = await storage.get(dispatchResult.taskId); + const taskRow = await storage.getTask(dispatchResult.taskId); expect(taskRow.status).toBe('failed'); }); @@ -142,10 +142,10 @@ describe('StorageTaskBroker', () => { const { taskId } = await broker.dispatch({ steps: [] }); const task = await broker.claim(); - const initialTask = await storage.get(taskId); + const initialTask = await storage.getTask(taskId); for (;;) { - const maybeTask = await storage.get(taskId); + const maybeTask = await storage.getTask(taskId); if (maybeTask.lastHeartbeatAt !== initialTask.lastHeartbeatAt) { break; } @@ -169,7 +169,7 @@ describe('StorageTaskBroker', () => { }, 500); for (;;) { - const maybeTask = await storage.get(taskId); + const maybeTask = await storage.getTask(taskId); if (maybeTask.status === 'failed') { break; } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index 48c0d5e5ba..d3b2c098c7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { JsonObject } from '@backstage/config'; import { Logger } from 'winston'; import { CompletedTaskState, @@ -22,6 +23,7 @@ import { TaskBroker, DispatchResult, DbTaskEventRow, + DbTaskRow, } from './types'; export class TaskAgent implements Task { @@ -54,10 +56,10 @@ export class TaskAgent implements Task { return this.isDone; } - async emitLog(message: string): Promise { + async emitLog(message: string, metadata?: JsonObject): Promise { await this.storage.emitLogEvent({ taskId: this.state.taskId, - body: { message }, + body: { message, ...metadata }, }); } @@ -136,6 +138,10 @@ export class StorageTaskBroker implements TaskBroker { }; } + async get(taskId: string): Promise { + return this.storage.getTask(taskId); + } + observe( options: { taskId: string; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index c62495ca4c..d2f3b88e65 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -51,59 +51,60 @@ export class TaskWorker { await task.getWorkspaceName(), ); await fs.ensureDir(workspacePath); - - const taskLogger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: winston.format.combine( - winston.format.colorize(), - winston.format.timestamp(), - winston.format.simple(), - ), - defaultMeta: {}, - }); - - const stream = new PassThrough(); - stream.on('data', data => { - const message = data.toString().trim(); - if (message?.length > 1) task.emitLog(message); - }); - - taskLogger.add(new winston.transports.Stream({ stream })); - - // Give us some time to curl observe - task.emitLog('Task claimed, waiting ...'); - await new Promise(resolve => setTimeout(resolve, 5000)); - task.emitLog(`Starting up work with ${task.spec.steps.length} steps`); + await task.emitLog( + `Starting up work with ${task.spec.steps.length} steps`, + ); const outputs: { [name: string]: JsonValue } = {}; for (const step of task.spec.steps) { - task.emitLog(`Beginning step ${step.name}`); + const metadata = { stepId: step.id }; + try { + const taskLogger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.colorize(), + winston.format.timestamp(), + winston.format.simple(), + ), + defaultMeta: {}, + }); - const action = actionRegistry.get(step.action); - if (!action) { - throw new Error(`Action '${step.action}' does not exist`); + const stream = new PassThrough(); + stream.on('data', data => { + const message = data.toString().trim(); + if (message?.length > 1) task.emitLog(message, metadata); + }); + + taskLogger.add(new winston.transports.Stream({ stream })); + await task.emitLog(`Beginning step ${step.name}`, metadata); + + const action = actionRegistry.get(step.action); + if (!action) { + throw new Error(`Action '${step.action}' does not exist`); + } + + // TODO: substitute any placeholders with output from previous steps + const parameters = step.parameters!; + + await action.handler({ + logger, + logStream: stream, + parameters, + workspacePath, + output(name: string, value: JsonValue) { + outputs[name] = value; + }, + }); + + await task.emitLog(`Finished step ${step.name}`, metadata); + } catch (error) { + await task.emitLog(String(error.stack), metadata); + throw error; } - - // TODO: substitute any placeholders with output from previous steps - const parameters = step.parameters!; - - await action.handler({ - logger, - logStream: stream, - parameters, - workspacePath, - output(name: string, value: JsonValue) { - outputs[name] = value; - }, - }); - - task.emitLog(`Finished step ${step.name}`); } - await task.complete('completed'); } catch (error) { - task.emitLog(String(error.stack)); await task.complete('failed'); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 0c2592109b..783528dbdf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -58,7 +58,7 @@ export type DispatchResult = { export interface Task { spec: TaskSpec; done: boolean; - emitLog(message: string): Promise; + emitLog(message: string, metadata?: JsonValue): Promise; complete(result: CompletedTaskState): Promise; getWorkspaceName(): Promise; } @@ -90,6 +90,7 @@ export type TaskStoreGetEventsOptions = { }; export interface TaskStore { createTask(task: TaskSpec): Promise<{ taskId: string }>; + getTask(taskId: string): Promise; claimTask(): Promise; completeTask(options: { taskId: string; From 10aa1f42267e42ccdb68482237ed4118c9f7dad0 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 8 Feb 2021 14:09:00 +0100 Subject: [PATCH 02/58] Add get task by taskID endpoint --- plugins/scaffolder-backend/src/service/router.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 5821edfe8c..e9239b8ea2 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -44,7 +44,10 @@ import { } from '../scaffolder/tasks/TemplateConverter'; import { registerLegacyActions } from '../scaffolder/stages/legacy'; import { getWorkingDirectory } from './helpers'; -import { PluginDatabaseManager } from '@backstage/backend-common'; +import { + NotFoundError, + PluginDatabaseManager, +} from '@backstage/backend-common'; export interface RouterOptions { preparers: PreparerBuilder; @@ -246,6 +249,16 @@ export async function createRouter( res.status(201).json({ id: result.taskId }); }) + .get('/v2/tasks/:taskId', async (req, res) => { + const { taskId } = req.params; + console.warn('getting task'); + const task = await taskBroker.get(taskId); + console.warn('got task', task); + if (!task) { + throw new NotFoundError(`task with id ${taskId} does not exist`); + } + res.status(200).json(task); + }) .get('/v2/tasks/:taskId/eventstream', async (req, res) => { const { taskId } = req.params; const after = Number(req.query.after) || undefined; From cb1d826cddabad4cf7f5514313b070d44a40a1e3 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 8 Feb 2021 17:03:17 +0100 Subject: [PATCH 03/58] wip --- plugins/scaffolder/package.json | 5 +- plugins/scaffolder/src/api.ts | 79 +++++++- .../JobStatusModal/JobStatusModal.tsx | 186 +++++++++++++++--- .../components/TemplatePage/TemplatePage.tsx | 76 +++---- .../{useJobPolling.ts => useTaskPolling.ts} | 54 ++--- plugins/scaffolder/src/types.ts | 18 ++ 6 files changed, 313 insertions(+), 105 deletions(-) rename plugins/scaffolder/src/components/hooks/{useJobPolling.ts => useTaskPolling.ts} (52%) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 56beb7b10b..10cc36809a 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -32,6 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.1", "@backstage/core": "^0.6.0", + "@backstage/config": "^0.1.2", "@backstage/plugin-catalog-react": "^0.0.2", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -48,13 +49,13 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "swr": "^0.3.0" + "swr": "^0.3.0", + "zen-observable": "^0.8.15" }, "devDependencies": { "@backstage/cli": "^0.6.0", "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", - "@backstage/catalog-client": "^0.3.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index ca52418e92..6b7f1b6181 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,13 +14,21 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { JsonObject } from '@backstage/config'; +import { createApiRef, DiscoveryApi, Observable } from '@backstage/core'; +import ObservableImpl from 'zen-observable'; +import { ScaffolderV2Task } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', description: 'Used to make requests towards the scaffolder backend', }); +type LogEvent = { + type: 'log' | 'completion'; + body: JsonObject; +}; + export class ScaffolderApi { private readonly discoveryApi: DiscoveryApi; @@ -36,7 +44,7 @@ export class ScaffolderApi { * @param values Parameters for the template, e.g. name, description */ async scaffold(templateName: string, values: Record) { - const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`; + const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v2/tasks`; const response = await fetch(url, { method: 'POST', headers: { @@ -52,12 +60,75 @@ export class ScaffolderApi { } const { id } = await response.json(); + + // 859d6b78-7c19-4d1a-abe0-9396ab3bd686 + // this.streamLogs({ + // taskId: id, + // }).then(observable => { + // console.log('DEBUG: observable =', observable); + // observable.subscribe({ + // next: thing => console.warn('next', thing), + // error: thing => console.warn('error', thing), + // complete: () => console.warn('complete'), + // }); + // }); + return id; } - async getJob(jobId: string) { + async getTask(taskId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const url = `${baseUrl}/v1/job/${encodeURIComponent(jobId)}`; + const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}`; return fetch(url).then(x => x.json()); } + + streamLogs({ + taskId, + after, + }: { + taskId: string; + after?: number; + }): Observable { + return new ObservableImpl(subscriber => { + const params = new URLSearchParams(); + if (after !== undefined) { + params.set('after', String(Number(after))); + } + + this.discoveryApi.getBaseUrl('scaffolder').then( + baseUrl => { + const url = `${baseUrl}/v2/tasks/${encodeURIComponent( + taskId, + )}/eventstream`; + const eventSource = new EventSource(url); + eventSource.addEventListener('log', event => { + if (event.data) { + try { + subscriber.next(JSON.parse(event.data)); + } catch (ex) { + subscriber.error(ex); + } + } + }); + eventSource.addEventListener('completion', event => { + eventSource.close(); + if (event.data) { + try { + subscriber.next(JSON.parse(event.data)); + } catch (ex) { + subscriber.error(ex); + } + } + subscriber.complete(); + }); + eventSource.addEventListener('error', event => { + subscriber.error(event); + }); + }, + error => { + subscriber.error(error); + }, + ); + }); + } } diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index d35385ec3b..597e6a243d 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button } from '@backstage/core'; +import { Button, Observable, Subscription, useApi } from '@backstage/core'; import { Button as Action, Dialog, @@ -23,64 +23,192 @@ import { LinearProgress, } from '@material-ui/core'; -import React, { useCallback } from 'react'; -import { Job } from '../../types'; -import { JobStage } from '../JobStage/JobStage'; +import React, { useCallback, useEffect, useReducer } from 'react'; +import { scaffolderApiRef } from '../../api'; +import { ScaffolderV2Task } from '../../types'; type Props = { - job: Job | null; + task: ScaffolderV2Task | null; toCatalogLink?: string; open: boolean; onModalClose: () => void; }; +type Step = { + id: string; + status: 'open' | 'processing' | 'failed' | 'completed'; +}; + +type ReducerState = { + loading: boolean; + error?: Error; + completed: boolean; + + task: ScaffolderV2Task; + + step: [{ [stepId in string]: Step }]; +}; + +type ReducerAction = + | { + type: 'INIT'; + data: ScaffolderV2Task; + } + | { type: 'EVENT'; data: { body: { stepId: string } } } + | { type: 'COMPLETED' } + | { type: 'ERROR'; data: Error }; + +function reducer(state: ReducerState, action: ReducerAction) { + const stepId = action.type === 'EVENT' ? action.data.body.stepId : 'global'; + const currentStep = state[stepId] ?? { log: [], status: 'open' }; + + switch (action.type) { + case 'INIT': + return { + ...state, + ...action.data, + }; + case 'EVENT': + return { + ...state, + [stepId]: { + ...currentStep, + log: [...currentStep.log, event], + }, + }; + case 'COMPLETED': + return { + ...state, + progress: 'done', + }; + case 'ERROR': + return { + error: action.error, + loading: false, + completed: false, + }; + default: + return state; + } +} + +/* + +{} + +{ + id: as'das + spec: { + steps: [ + { id, log: []} + ] + } +} + +*/ +const useTaskEventStream = (taskId: string) => { + // fetch task + + const scaffolderApi = useApi(scaffolderApiRef); + const [state, dispatch] = useReducer(reducer, { + loading: true, + }); + useEffect(() => { + let didCancel = false; + let subscription: Subscription | undefined; + + scaffolderApi.getTask(taskId).then( + task => { + if (didCancel) { + return; + } + dispatch({ type: 'INIT', data: task }); + const observable = scaffolderApi.streamLogs({ taskId }); + subscription = observable.subscribe({ + next: event => dispatch({ type: 'EVENT', data: event }), + error: error => dispatch({ type: 'ERROR', data: error }), + complete: () => dispatch({ type: 'COMPLETED' }), + }); + }, + error => { + if (!didCancel) { + dispatch({ type: 'ERROR', data: error }); + } + }, + ); + + return () => { + didCancel = true; + if (subscription) { + subscription.unsubscribe(); + } + }; + }); + + return state; + + // subscribe to observable, + + // on observer change update the step logs etc + status + + // return { + // steps: { + // stepId: { + // log: [], + // status: + // } + // } + // } +}; + export const JobStatusModal = ({ - job, + task, toCatalogLink, open, onModalClose, }: Props) => { + const model = useTaskEventStream(task?.id!); + console.warn(model); const renderTitle = () => { - switch (job?.status) { - case 'COMPLETED': + switch (task?.status) { + case 'completed': return 'Successfully created component'; - case 'FAILED': + case 'failed': return 'Failed to create component'; default: return 'Create component'; } }; - const onClose = useCallback(() => { - if (!job) { + if (!task) { return; } // Disallow closing modal if the job is in progress. - if (job.status === 'COMPLETED' || job.status === 'FAILED') { + if (task.status !== 'processing') { onModalClose(); } - }, [job, onModalClose]); + }, [task, onModalClose]); return ( {renderTitle()} - {!job ? ( - - ) : ( - (job?.stages ?? []).map(step => ( - - )) - )} + {/* {!task ? ( */} + + {/* ) : ( */} + {/* (task?.spec.steps ?? []).map(step => ( */} + {/* */} + {/* )) */} + {/* )} */} - {job?.status && toCatalogLink && ( + {/* {job?.status && toCatalogLink && ( @@ -89,7 +217,7 @@ export const JobStatusModal = ({ Close - )} + )} */} ); }; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index e1422d4ec1..a3c7ceee12 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -20,6 +20,7 @@ import { Header, InfoCard, Lifecycle, + Observable, Page, useApi, } from '@backstage/core'; @@ -37,7 +38,7 @@ import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRoute } from '../../routes'; -import { useJobPolling } from '../hooks/useJobPolling'; +import { useTaskPolling } from '../hooks/useTaskPolling'; import { JobStatusModal } from '../JobStatusModal'; import { MultistepJsonForm } from '../MultistepJsonForm'; @@ -91,41 +92,39 @@ export const TemplatePage = () => { [setFormState, formState], ); - const [jobId, setJobId] = useState(null); - const job = useJobPolling(jobId, async jobItem => { - if (!jobItem.metadata.catalogInfoUrl) { - errorApi.post( - new Error(`No catalogInfoUrl returned from the scaffolder`), - ); - return; - } - - try { - const { - entities: [createdEntity], - } = await catalogApi.addLocation({ - target: jobItem.metadata.catalogInfoUrl, - }); - - 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 [taskId, setTaskId] = useState(null); + const task = useTaskPolling(taskId, async task => { + console.warn('onFinish is called'); + // if (!jobItem.metadata.catalogInfoUrl) { + // errorApi.post( + // new Error(`No catalogInfoUrl returned from the scaffolder`), + // ); + // return; + // } + // try { + // const { + // entities: [createdEntity], + // } = await catalogApi.addLocation({ + // target: jobItem.metadata.catalogInfoUrl, + // }); + // 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 id = await scaffolderApi.scaffold(templateName, formState); - setJobId(id); + setTaskId(id); setModalOpen(true); } catch (e) { errorApi.post(e); @@ -159,12 +158,15 @@ export const TemplatePage = () => { /> {loading && } - setModalOpen(false)} - /> + {task && ( + setModalOpen(false)} + /> + )} + ) {template && ( void, +export const useTaskPolling = ( + taskId: string | null, + onFinish?: (t: ScaffolderV2Task) => void, pollingInterval = DEFAULT_POLLING_INTERVAL, ) => { const scaffolderApi = useApi(scaffolderApiRef); - const [currentJob, setCurrentJob] = useState(null); + const [currentTask, setCurrentTask] = useState(null); + + useInterval(async () => { + if (taskId) { + setCurrentTask(await scaffolderApi.getTask(taskId)); + } + }, pollingInterval); useEffect(() => { - const resetCurrentJob = async () => { - if (jobId) { - const job = await scaffolderApi.getJob(jobId); - setCurrentJob(job); - } - }; + switch (currentTask?.status) { + case 'failed': + case 'cancelled': + case 'completed': + return onFinish?.(currentTask); + default: + return undefined; + } + }, [currentTask, onFinish]); - resetCurrentJob(); - }, [jobId, scaffolderApi]); - - 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); - } - }, - shouldBeRunningInterval ? pollingInterval : null, - ); - - return currentJob; + return currentTask; }; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 45672c603d..8cdf22c18b 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { JsonValue } from '@backstage/config'; export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; export type Job = { @@ -35,3 +36,20 @@ export type Stage = { startedAt: string; endedAt?: string; }; + +export type ScaffolderV2Step = { + id: string; + name: string; + action: string; + parameters?: { [name: string]: JsonValue }; +}; + +export type ScaffolderV2Task = { + id: string; + spec: { + steps: ScaffolderV2Step[]; + }; + status: 'failed' | 'completed' | 'processing' | 'open' | 'cancelled'; + lastHeartbeatAt: string; + createdAt: string; +}; From 554209a0632d2bfa2f84c8c84f5014ddc93da500 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 9 Feb 2021 16:48:31 +0100 Subject: [PATCH 04/58] emot step status --- .../src/scaffolder/tasks/TaskWorker.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index d2f3b88e65..66d1331c5c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -77,7 +77,10 @@ export class TaskWorker { }); taskLogger.add(new winston.transports.Stream({ stream })); - await task.emitLog(`Beginning step ${step.name}`, metadata); + await task.emitLog(`Beginning step ${step.name}`, { + ...metadata, + status: 'processing', + }); const action = actionRegistry.get(step.action); if (!action) { @@ -97,9 +100,15 @@ export class TaskWorker { }, }); - await task.emitLog(`Finished step ${step.name}`, metadata); + await task.emitLog(`Finished step ${step.name}`, { + ...metadata, + status: 'completed', + }); } catch (error) { - await task.emitLog(String(error.stack), metadata); + await task.emitLog(String(error.stack), { + ...metadata, + status: 'failed', + }); throw error; } } From 593251b69dd7e2794a5d7d9f95264fdf1517a746 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 9 Feb 2021 16:53:24 +0100 Subject: [PATCH 05/58] add TaskPage component --- plugins/scaffolder/dev/index.tsx | 5 + plugins/scaffolder/package.json | 3 +- plugins/scaffolder/src/api.ts | 25 +-- .../src/components/JobStage/JobStage.tsx | 14 +- .../JobStatusModal/JobStatusModal.tsx | 171 ++---------------- .../src/components/TaskPage/TaskPage.tsx | 121 +++++++++++++ .../src/components/TaskPage/index.ts | 16 ++ .../components/TemplatePage/TemplatePage.tsx | 42 +---- .../src/components/hooks/useEventStream.ts | 170 +++++++++++++++++ .../src/components/hooks/useTaskPolling.ts | 50 ----- plugins/scaffolder/src/index.ts | 3 +- plugins/scaffolder/src/plugin.ts | 12 +- plugins/scaffolder/src/routes.ts | 5 + plugins/scaffolder/src/types.ts | 6 +- 14 files changed, 373 insertions(+), 270 deletions(-) create mode 100644 plugins/scaffolder/src/components/TaskPage/TaskPage.tsx create mode 100644 plugins/scaffolder/src/components/TaskPage/index.ts create mode 100644 plugins/scaffolder/src/components/hooks/useEventStream.ts delete mode 100644 plugins/scaffolder/src/components/hooks/useTaskPolling.ts diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index eff16ab0f5..5bdeec3e10 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -21,6 +21,7 @@ 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'; createDevApp() .registerApi({ @@ -42,4 +43,8 @@ createDevApp() path: '/create/:templateName', element: , }) + .addPage({ + path: '/scaffolder/tasks/:taskId', + element: , + }) .render(); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 10cc36809a..79ea075e3d 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -50,7 +50,8 @@ "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", "swr": "^0.3.0", - "zen-observable": "^0.8.15" + "zen-observable": "^0.8.15", + "use-immer": "^0.4.2" }, "devDependencies": { "@backstage/cli": "^0.6.0", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 6b7f1b6181..1d6bb2007b 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -17,7 +17,7 @@ import { JsonObject } from '@backstage/config'; import { createApiRef, DiscoveryApi, Observable } from '@backstage/core'; import ObservableImpl from 'zen-observable'; -import { ScaffolderV2Task } from './types'; +import { ScaffolderTask } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', @@ -27,6 +27,9 @@ export const scaffolderApiRef = createApiRef({ type LogEvent = { type: 'log' | 'completion'; body: JsonObject; + createdAt: string; + id: string; + taskId: string; }; export class ScaffolderApi { @@ -60,23 +63,10 @@ export class ScaffolderApi { } const { id } = await response.json(); - - // 859d6b78-7c19-4d1a-abe0-9396ab3bd686 - // this.streamLogs({ - // taskId: id, - // }).then(observable => { - // console.log('DEBUG: observable =', observable); - // observable.subscribe({ - // next: thing => console.warn('next', thing), - // error: thing => console.warn('error', thing), - // complete: () => console.warn('complete'), - // }); - // }); - return id; } - async getTask(taskId: string): Promise { + async getTask(taskId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}`; return fetch(url).then(x => x.json()); @@ -101,7 +91,7 @@ export class ScaffolderApi { taskId, )}/eventstream`; const eventSource = new EventSource(url); - eventSource.addEventListener('log', event => { + eventSource.addEventListener('log', (event: any) => { if (event.data) { try { subscriber.next(JSON.parse(event.data)); @@ -110,8 +100,7 @@ export class ScaffolderApi { } } }); - eventSource.addEventListener('completion', event => { - eventSource.close(); + eventSource.addEventListener('completion', (event: any) => { if (event.data) { try { subscriber.next(JSON.parse(event.data)); diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx index f285cb98c0..1448fedf5c 100644 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ b/plugins/scaffolder/src/components/JobStage/JobStage.tsx @@ -104,11 +104,11 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { const [expanded, setExpanded] = useState(false); useEffect(() => { - if (status === 'FAILED') setExpanded(true); + if (status === 'failed') setExpanded(true); }, [status, setExpanded]); const timeElapsed = - status !== 'PENDING' + status === 'processing' ? moment .duration(moment(endedAt ?? moment()).diff(moment(startedAt))) .humanize() @@ -143,9 +143,13 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { {log.length === 0 ? ( - - No logs available for this step - +
+ +
) : ( }> void; -}; - -type Step = { - id: string; - status: 'open' | 'processing' | 'failed' | 'completed'; -}; - -type ReducerState = { - loading: boolean; - error?: Error; - completed: boolean; - - task: ScaffolderV2Task; - - step: [{ [stepId in string]: Step }]; -}; - -type ReducerAction = - | { - type: 'INIT'; - data: ScaffolderV2Task; - } - | { type: 'EVENT'; data: { body: { stepId: string } } } - | { type: 'COMPLETED' } - | { type: 'ERROR'; data: Error }; - -function reducer(state: ReducerState, action: ReducerAction) { - const stepId = action.type === 'EVENT' ? action.data.body.stepId : 'global'; - const currentStep = state[stepId] ?? { log: [], status: 'open' }; - - switch (action.type) { - case 'INIT': - return { - ...state, - ...action.data, - }; - case 'EVENT': - return { - ...state, - [stepId]: { - ...currentStep, - log: [...currentStep.log, event], - }, - }; - case 'COMPLETED': - return { - ...state, - progress: 'done', - }; - case 'ERROR': - return { - error: action.error, - loading: false, - completed: false, - }; - default: - return state; - } -} - -/* - -{} - -{ - id: as'das - spec: { - steps: [ - { id, log: []} - ] - } -} - -*/ -const useTaskEventStream = (taskId: string) => { - // fetch task - - const scaffolderApi = useApi(scaffolderApiRef); - const [state, dispatch] = useReducer(reducer, { - loading: true, - }); - useEffect(() => { - let didCancel = false; - let subscription: Subscription | undefined; - - scaffolderApi.getTask(taskId).then( - task => { - if (didCancel) { - return; - } - dispatch({ type: 'INIT', data: task }); - const observable = scaffolderApi.streamLogs({ taskId }); - subscription = observable.subscribe({ - next: event => dispatch({ type: 'EVENT', data: event }), - error: error => dispatch({ type: 'ERROR', data: error }), - complete: () => dispatch({ type: 'COMPLETED' }), - }); - }, - error => { - if (!didCancel) { - dispatch({ type: 'ERROR', data: error }); - } - }, - ); - - return () => { - didCancel = true; - if (subscription) { - subscription.unsubscribe(); - } - }; - }); - - return state; - - // subscribe to observable, - - // on observer change update the step logs etc + status - - // return { - // steps: { - // stepId: { - // log: [], - // status: - // } - // } - // } -}; - export const JobStatusModal = ({ task, toCatalogLink, open, onModalClose, }: Props) => { - const model = useTaskEventStream(task?.id!); - console.warn(model); + const eventStream = useTaskEventStream(task?.id!); + const renderTitle = () => { switch (task?.status) { case 'completed': @@ -189,24 +50,24 @@ export const JobStatusModal = ({ } }, [task, onModalClose]); + console.log(eventStream); + return ( {renderTitle()} - {/* {!task ? ( */} - - {/* ) : ( */} - {/* (task?.spec.steps ?? []).map(step => ( */} - {/* */} - {/* )) */} - {/* )} */} + {task?.spec.steps + .filter(step => !!eventStream?.steps?.[step.id]) + .map(step => ( + + ))} {/* {job?.status && toCatalogLink && ( diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx new file mode 100644 index 0000000000..6debff5b0e --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2021 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 { Page, Header, Lifecycle, Content } from '@backstage/core'; +import React from 'react'; +import { makeStyles, Theme, createStyles } 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 Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +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'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + }, + button: { + marginTop: theme.spacing(1), + marginRight: theme.spacing(1), + }, + actionsContainer: { + marginBottom: theme.spacing(2), + }, + resetContainer: { + padding: theme.spacing(3), + }, + }), +); + +export const TaskStepper = ({ taskStream }: { taskStream: TaskStream }) => { + const classes = useStyles(); + const [activeStep, setActiveStep] = React.useState(0); + const steps = taskStream?.task?.spec.steps ?? []; + + const handleNext = () => { + setActiveStep(prevActiveStep => prevActiveStep + 1); + }; + + const handleBack = () => { + setActiveStep(prevActiveStep => prevActiveStep - 1); + }; + + const handleReset = () => { + setActiveStep(0); + }; + + return ( +
+ + {steps.map((step, index) => ( + + + {step.name} + + +
+ +
+
+
+ ))} +
+ {activeStep === steps.length && ( + + All steps completed - you're finished + + + )} +
+ ); +}; + +export const TaskPage = () => { + const { taskId } = useParams(); + const taskStream = useTaskEventStream(taskId); + + return ( + +
+ Task Activity + + } + subtitle={`Activity for task: ${taskId}`} + /> + + + + + ); +}; diff --git a/plugins/scaffolder/src/components/TaskPage/index.ts b/plugins/scaffolder/src/components/TaskPage/index.ts new file mode 100644 index 0000000000..3695c2792e --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 { TaskPage } from './TaskPage'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index a3c7ceee12..d9a00d0dda 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -20,15 +20,10 @@ import { Header, InfoCard, Lifecycle, - Observable, Page, useApi, } from '@backstage/core'; -import { - catalogApiRef, - entityRoute, - entityRouteParams, -} from '@backstage/plugin-catalog-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; @@ -38,6 +33,7 @@ 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 { MultistepJsonForm } from '../MultistepJsonForm'; @@ -77,6 +73,7 @@ const OWNER_REPO_SCHEMA = { }, }, }; + export const TemplatePage = () => { const errorApi = useApi(errorApiRef); const catalogApi = useApi(catalogApiRef); @@ -92,39 +89,13 @@ export const TemplatePage = () => { [setFormState, formState], ); - const [taskId, setTaskId] = useState(null); - const task = useTaskPolling(taskId, async task => { - console.warn('onFinish is called'); - // if (!jobItem.metadata.catalogInfoUrl) { - // errorApi.post( - // new Error(`No catalogInfoUrl returned from the scaffolder`), - // ); - // return; - // } - // try { - // const { - // entities: [createdEntity], - // } = await catalogApi.addLocation({ - // target: jobItem.metadata.catalogInfoUrl, - // }); - // 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 [task, setTask] = useState(undefined); const handleCreate = async () => { try { const id = await scaffolderApi.scaffold(templateName, formState); - setTaskId(id); + const returned = await scaffolderApi.getTask(id); + setTask(returned); setModalOpen(true); } catch (e) { errorApi.post(e); @@ -166,7 +137,6 @@ export const TemplatePage = () => { onModalClose={() => setModalOpen(false)} /> )} - ) {template && ( { + current[next.id] = { log: [], status: 'open', id: next.id }; + return current; + }, {} as { [stepId in string]: Step }); + draft.loading = false; + draft.error = undefined; + draft.completed = false; + draft.task = action.data; + draft.log = []; + return; + } + + case 'LOG': { + const stepId = action.data.body.stepId ?? 'global'; + const currentStep = draft.steps?.[stepId]; + const logLine = `${action.data.createdAt} ${action.data.body.message}`; + + if (!currentStep) { + draft.log.push(logLine); + return; + } + + currentStep.log.push(logLine); + + if ( + action.data.body.status && + action.data.body.status !== currentStep.status + ) { + currentStep.status = action.data.body.status; + + if (currentStep.status === 'processing') { + currentStep.startedAt = action.data.createdAt; + } + + if ( + ['zcancelled', 'failed', 'completed'].includes(currentStep.status) + ) { + currentStep.endedAt = action.data.createdAt; + } + } + + return; + } + + case 'COMPLETED': { + draft.completed = true; + return; + } + + case 'ERROR': { + draft.error = action.data; + draft.loading = false; + draft.completed = true; + return; + } + + default: + return; + } +} + +export const useTaskEventStream = (taskId: string): TaskStream => { + const scaffolderApi = useApi(scaffolderApiRef); + const [state, dispatch] = useImmerReducer(reducer, { + loading: true, + completed: false, + log: [], + steps: {} as { [stepId in string]: Step }, + }); + + useEffect(() => { + let didCancel = false; + let subscription: Subscription | undefined; + + scaffolderApi.getTask(taskId).then( + task => { + if (didCancel) { + return; + } + dispatch({ type: 'INIT', data: task }); + const observable = scaffolderApi.streamLogs({ taskId }); + subscription = observable.subscribe({ + next: event => { + switch (event.type) { + case 'log': + return dispatch({ type: 'LOG', data: event }); + default: + throw new Error( + `Unhandled event type ${event.type} in observer`, + ); + } + }, + error: error => dispatch({ type: 'ERROR', data: error }), + complete: () => dispatch({ type: 'COMPLETED' }), + }); + }, + error => { + if (!didCancel) { + dispatch({ type: 'ERROR', data: error }); + } + }, + ); + + return () => { + didCancel = true; + if (subscription) { + subscription.unsubscribe(); + } + }; + }, [scaffolderApi, dispatch, taskId]); + + return state; +}; diff --git a/plugins/scaffolder/src/components/hooks/useTaskPolling.ts b/plugins/scaffolder/src/components/hooks/useTaskPolling.ts deleted file mode 100644 index dbe6c67b89..0000000000 --- a/plugins/scaffolder/src/components/hooks/useTaskPolling.ts +++ /dev/null @@ -1,50 +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 { useEffect, useState } from 'react'; -import { ScaffolderV2Task } from '../../types'; -import { useApi } from '@backstage/core'; -import { scaffolderApiRef } from '../../api'; -import { useInterval } from 'react-use'; - -const DEFAULT_POLLING_INTERVAL = 1000; - -export const useTaskPolling = ( - taskId: string | null, - onFinish?: (t: ScaffolderV2Task) => void, - pollingInterval = DEFAULT_POLLING_INTERVAL, -) => { - const scaffolderApi = useApi(scaffolderApiRef); - const [currentTask, setCurrentTask] = useState(null); - - useInterval(async () => { - if (taskId) { - setCurrentTask(await scaffolderApi.getTask(taskId)); - } - }, pollingInterval); - - useEffect(() => { - switch (currentTask?.status) { - case 'failed': - case 'cancelled': - case 'completed': - return onFinish?.(currentTask); - default: - return undefined; - } - }, [currentTask, onFinish]); - - return currentTask; -}; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 5f102e7853..e87e8d0107 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -19,6 +19,7 @@ export { scaffolderPlugin as plugin, TemplateIndexPage, TemplatePage, + TaskPage, } from './plugin'; export { ScaffolderApi, scaffolderApiRef } from './api'; -export { rootRoute, templateRoute } from './routes'; +export { rootRoute, templateRoute, taskRoute } from './routes'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index ed20705185..d8c20618f3 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -22,7 +22,8 @@ import { } from '@backstage/core'; import { ScaffolderPage as ScaffolderPageComponent } from './components/ScaffolderPage'; import { TemplatePage as TemplatePageComponent } from './components/TemplatePage'; -import { rootRoute, templateRoute } from './routes'; +import { TaskPage as TaskPageComponent } from './components/TaskPage'; +import { rootRoute, templateRoute, taskRoute } from './routes'; import { scaffolderApiRef, ScaffolderApi } from './api'; export const scaffolderPlugin = createPlugin({ @@ -37,10 +38,12 @@ export const scaffolderPlugin = createPlugin({ register({ router }) { router.addRoute(rootRoute, ScaffolderPageComponent); router.addRoute(templateRoute, TemplatePageComponent); + router.addRoute(taskRoute, TaskPageComponent); }, routes: { templateIndex: rootRoute, template: templateRoute, + task: taskRoute, }, }); @@ -59,3 +62,10 @@ export const TemplatePage = scaffolderPlugin.provide( mountPoint: templateRoute, }), ); + +export const TaskPage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => import('./components/TaskPage').then(m => m.TaskPage), + mountPoint: taskRoute, + }), +); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 8efd1d3aaf..09af4717aa 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -24,3 +24,8 @@ export const templateRoute = createRouteRef({ path: '/create/:templateName', title: 'Entity creation', }); + +export const taskRoute = createRouteRef({ + path: '/scaffolder/task/:taskId', + title: 'Task information', +}); diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 8cdf22c18b..fe1aec5286 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -37,17 +37,17 @@ export type Stage = { endedAt?: string; }; -export type ScaffolderV2Step = { +export type ScaffolderStep = { id: string; name: string; action: string; parameters?: { [name: string]: JsonValue }; }; -export type ScaffolderV2Task = { +export type ScaffolderTask = { id: string; spec: { - steps: ScaffolderV2Step[]; + steps: ScaffolderStep[]; }; status: 'failed' | 'completed' | 'processing' | 'open' | 'cancelled'; lastHeartbeatAt: string; From 36d0954886fe0dde7503253a62b33bca47b57b66 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 10 Feb 2021 11:41:22 +0100 Subject: [PATCH 06/58] refactor scaffolder --- .../software-templates/installation.md | 19 --- plugins/scaffolder/dev/index.tsx | 7 +- plugins/scaffolder/package.json | 7 +- plugins/scaffolder/src/api.ts | 29 +++- .../JobStatusModal/JobStatusModal.tsx | 84 --------- .../src/components/JobStatusModal/index.ts | 16 -- .../src/components/TaskPage/TaskPage.tsx | 160 +++++++++++++++--- .../components/TemplatePage/TemplatePage.tsx | 29 ++-- plugins/scaffolder/src/index.ts | 3 +- plugins/scaffolder/src/plugin.ts | 4 +- plugins/scaffolder/src/routes.ts | 1 + 11 files changed, 185 insertions(+), 174 deletions(-) delete mode 100644 plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx delete mode 100644 plugins/scaffolder/src/components/JobStatusModal/index.ts diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 8d2fa7727d..a02755c9c7 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -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! diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 5bdeec3e10..250a59e775 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -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', diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 79ea075e3d..b332c37c28 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 1d6bb2007b..c51bd4a8be 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -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): Promise; + + getTask(taskId: string): Promise; + + streamLogs({ + taskId, + after, + }: { + taskId: string; + after?: number; + }): Observable; +} +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) { + async scaffold( + templateName: string, + values: Record, + ): Promise { 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; } diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx deleted file mode 100644 index 7ef6454588..0000000000 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ /dev/null @@ -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 ( - - {renderTitle()} - - {task?.spec.steps - .filter(step => !!eventStream?.steps?.[step.id]) - .map(step => ( - - ))} - - {/* {job?.status && toCatalogLink && ( - - - - )} - {job?.status === 'FAILED' && ( - - Close - - )} */} - - ); -}; diff --git a/plugins/scaffolder/src/components/JobStatusModal/index.ts b/plugins/scaffolder/src/components/JobStatusModal/index.ts deleted file mode 100644 index 5598999fe3..0000000000 --- a/plugins/scaffolder/src/components/JobStatusModal/index.ts +++ /dev/null @@ -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'; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 6debff5b0e..2f22cb9849 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -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 ; + } + + if (completed) { + return ; + } + + if (active) { + return
; + } + return undefined; + }; + return ( +
+ {getComponent()} +
+ ); +} 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 (
- - {steps.map((step, index) => ( - - - {step.name} - - -
- -
-
-
- ))} + + + + + {steps.map((step, index) => { + const isCompleted = + taskStream.steps?.[step.id].status === 'completed'; + const isFailed = taskStream.steps?.[step.id].status === 'failed'; + return ( + + handleStep(index)}> + + {step.name} + + + + +
+ +
+
+
+ ); + })}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index d9a00d0dda..7239781b5c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -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(); + 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(undefined); + const [taskId, setTaskId] = useState(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 = () => { /> {loading && } - {task && ( - setModalOpen(false)} - /> - )} {template && ( new ScaffolderApi({ discoveryApi }), + factory: ({ discoveryApi }) => new ScaffolderClient({ discoveryApi }), }), ], register({ router }) { diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 09af4717aa..6b55f89ff1 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -28,4 +28,5 @@ export const templateRoute = createRouteRef({ export const taskRoute = createRouteRef({ path: '/scaffolder/task/:taskId', title: 'Task information', + params: ['taskId'], }); From 3796a666e0d3c4b3e470a3fe1b3391473cba238a Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 10 Feb 2021 11:42:04 +0100 Subject: [PATCH 07/58] scaffolder-backend: convert string to number --- .../src/scaffolder/tasks/DatabaseTaskStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts index 4190a5d58f..1dee11ba20 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -255,7 +255,7 @@ export class DatabaseTaskStore implements TaskStore { try { const body = JSON.parse(event.body) as JsonObject; return { - id: event.id, + id: Number(event.id), taskId, body, type: event.event_type, From efbd9c9a9928a68fa18d5bd6fd700cd2b20900ce Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 10 Feb 2021 14:00:51 +0100 Subject: [PATCH 08/58] Add TaskStep component --- .../src/components/TaskPage/TaskPage.tsx | 124 ++++++++++++------ .../src/components/hooks/useEventStream.ts | 31 ++--- 2 files changed, 101 insertions(+), 54 deletions(-) diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 2f22cb9849..820e65d126 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -15,7 +15,7 @@ */ import { Page, Header, Lifecycle, Content } from '@backstage/core'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, memo } from 'react'; import { makeStyles, Theme, @@ -34,7 +34,11 @@ 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 { + useTaskEventStream, + TaskStream, + Status, +} from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; import { StepButton, StepIconProps } from '@material-ui/core'; @@ -130,36 +134,29 @@ const useStyles = makeStyles((theme: Theme) => }), ); -export const TaskStepper = ({ taskStream }: { taskStream: TaskStream }) => { +type Steps = { + log: string[]; + id: string; + name: string; + status: Status; +}; + +export const TaskStepper = ({ steps }: { steps: Steps[] }) => { const classes = useStyles(); const [activeStep, setActiveStep] = useState(0); const [expandAll, setExpandAll] = useState(false); - const steps = taskStream?.task?.spec.steps ?? []; - - const handleNext = () => { - setActiveStep(prevActiveStep => prevActiveStep + 1); - }; - - const handleBack = () => { - 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 => + const activeIndex = steps.findIndex(step => ['failed', 'processing'].includes(step.status), ); - setActiveStep(activeIndex); - }, [taskStream]); + setActiveStep(2); + }, [steps]); return (
@@ -170,12 +167,24 @@ export const TaskStepper = ({ taskStream }: { taskStream: TaskStream }) => { {steps.map((step, index) => { - const isCompleted = - taskStream.steps?.[step.id].status === 'completed'; - const isFailed = taskStream.steps?.[step.id].status === 'failed'; + const isCompleted = step.status === 'completed'; + const isFailed = step.status === 'failed'; + // return ( + // + // ); + return ( - - handleStep(index)}> + + {}}> {
@@ -200,21 +205,62 @@ export const TaskStepper = ({ taskStream }: { taskStream: TaskStream }) => { ); })}
- {activeStep === steps.length && ( - - All steps completed - you're finished - - - )}
); }; +type TaskStepOptions = { + name: string; + isCompleted: boolean; + isFailed: boolean; + expanded: boolean; + handleStep: () => void; + index: number; + log: string; +}; + +export const TaskStep = memo( + ({ + log, + name, + isCompleted, + isFailed, + handleStep, + index, + expanded, + }: TaskStepOptions) => { + const onClick = React.useCallback(() => handleStep(index), [ + handleStep, + index, + ]); + return ( + + + + {name} + + + + +
+ +
+
+
+ ); + }, +); export const TaskPage = () => { const { taskId } = useParams(); const taskStream = useTaskEventStream(taskId); + const steps = + taskStream.task?.spec.steps.map(step => ({ + ...step, + ...taskStream?.steps?.[step.id], + })) ?? []; return ( @@ -228,7 +274,7 @@ export const TaskPage = () => { subtitle={`Activity for task: ${taskId}`} /> - + ); diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts index 2cbf8b4f83..20338463c3 100644 --- a/plugins/scaffolder/src/components/hooks/useEventStream.ts +++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts @@ -14,16 +14,16 @@ * limitations under the License. */ import { useImmerReducer } from 'use-immer'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { scaffolderApiRef } from '../../api'; import { ScaffolderTask } from '../../types'; +import { useDebounce } from 'react-use'; import { Subscription, useApi } from '@backstage/core'; -type Status = 'open' | 'processing' | 'failed' | 'completed'; +export type Status = 'open' | 'processing' | 'failed' | 'completed'; type Step = { id: string; status: Status; - log: string[]; endedAt?: string; startedAt?: string; }; @@ -34,7 +34,7 @@ export type TaskStream = { log: string[]; completed: boolean; task?: ScaffolderTask; - steps?: { [stepId in string]: Step }; + steps: { [stepId in string]: Step }; }; type ReducerAction = @@ -56,7 +56,7 @@ function reducer(draft: TaskStream, action: ReducerAction) { switch (action.type) { case 'INIT': { draft.steps = action.data.spec.steps.reduce((current, next) => { - current[next.id] = { log: [], status: 'open', id: next.id }; + current[next.id] = { status: 'open', id: next.id }; return current; }, {} as { [stepId in string]: Step }); draft.loading = false; @@ -72,12 +72,7 @@ function reducer(draft: TaskStream, action: ReducerAction) { const currentStep = draft.steps?.[stepId]; const logLine = `${action.data.createdAt} ${action.data.body.message}`; - if (!currentStep) { - draft.log.push(logLine); - return; - } - - currentStep.log.push(logLine); + draft.log.push(logLine); if ( action.data.body.status && @@ -89,9 +84,7 @@ function reducer(draft: TaskStream, action: ReducerAction) { currentStep.startedAt = action.data.createdAt; } - if ( - ['zcancelled', 'failed', 'completed'].includes(currentStep.status) - ) { + if (['cancelled', 'failed', 'completed'].includes(currentStep.status)) { currentStep.endedAt = action.data.createdAt; } } @@ -124,7 +117,14 @@ export const useTaskEventStream = (taskId: string): TaskStream => { log: [], steps: {} as { [stepId in string]: Step }, }); - + // const [debouncedState, setDebouncedState] = useState(state); + // useDebounce( + // () => { + // setDebouncedState(state); + // }, + // 1000, + // [state], + // ); useEffect(() => { let didCancel = false; let subscription: Subscription | undefined; @@ -167,4 +167,5 @@ export const useTaskEventStream = (taskId: string): TaskStream => { }, [scaffolderApi, dispatch, taskId]); return state; + // return debouncedState; }; From 2876700adb883d876183226cc6ca833f64434860 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 10 Feb 2021 15:24:00 +0100 Subject: [PATCH 09/58] Unifify logs --- plugins/scaffolder/src/api.ts | 10 +- .../src/components/TaskPage/TaskPage.tsx | 185 ++++-------------- .../components/TemplatePage/TemplatePage.tsx | 3 - .../src/components/hooks/useEventStream.ts | 106 +++++----- plugins/scaffolder/src/types.ts | 1 + 5 files changed, 104 insertions(+), 201 deletions(-) diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index c51bd4a8be..afb84fe173 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -17,16 +17,20 @@ import { JsonObject } from '@backstage/config'; import { createApiRef, DiscoveryApi, Observable } from '@backstage/core'; import ObservableImpl from 'zen-observable'; -import { ScaffolderTask } from './types'; +import { ScaffolderTask, Status } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', description: 'Used to make requests towards the scaffolder backend', }); -type LogEvent = { +export type LogEvent = { type: 'log' | 'completion'; - body: JsonObject; + body: { + message: string; + stepId?: string; + status?: Status; + }; createdAt: string; id: string; taskId: string; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 820e65d126..d6065fbd26 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -27,6 +27,7 @@ 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 Grid from '@material-ui/core/Grid'; import Button from '@material-ui/core/Button'; import Paper from '@material-ui/core/Paper'; import clsx from 'clsx'; @@ -42,80 +43,6 @@ import { 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 ; - } - - if (completed) { - return ; - } - - if (active) { - return
; - } - return undefined; - }; - return ( -
- {getComponent()} -
- ); -} - const useStyles = makeStyles((theme: Theme) => createStyles({ root: { @@ -141,13 +68,11 @@ type Steps = { status: Status; }; -export const TaskStepper = ({ steps }: { steps: Steps[] }) => { +export const TaskStatusStepper = memo(({ steps }: { steps: Steps[] }) => { const classes = useStyles(); const [activeStep, setActiveStep] = useState(0); - const [expandAll, setExpandAll] = useState(false); const handleStep = (step: number) => { - setExpandAll(false); setActiveStep(step); }; @@ -155,104 +80,50 @@ export const TaskStepper = ({ steps }: { steps: Steps[] }) => { const activeIndex = steps.findIndex(step => ['failed', 'processing'].includes(step.status), ); - setActiveStep(2); + setActiveStep(activeIndex); }, [steps]); return (
- - - {steps.map((step, index) => { const isCompleted = step.status === 'completed'; const isFailed = step.status === 'failed'; - // return ( - // - // ); - return ( {}}> {step.name} - - -
- -
-
); })}
); +}); + +const TaskLogger = memo(({ log }: { log: string }) => { + console.log('rendering my log'); + return ( +
+ +
+ ); +}); + +const TaskActionsBar = () => { + return ( + <> + + + + ); }; -type TaskStepOptions = { - name: string; - isCompleted: boolean; - isFailed: boolean; - expanded: boolean; - handleStep: () => void; - index: number; - log: string; -}; - -export const TaskStep = memo( - ({ - log, - name, - isCompleted, - isFailed, - handleStep, - index, - expanded, - }: TaskStepOptions) => { - const onClick = React.useCallback(() => handleStep(index), [ - handleStep, - index, - ]); - return ( - - - - {name} - - - - -
- -
-
-
- ); - }, -); export const TaskPage = () => { const { taskId } = useParams(); const taskStream = useTaskEventStream(taskId); @@ -274,7 +145,19 @@ export const TaskPage = () => { subtitle={`Activity for task: ${taskId}`} /> - + + + + + + + + + ); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 7239781b5c..85f3d955ac 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -89,12 +89,9 @@ export const TemplatePage = () => { [setFormState, formState], ); - const [taskId, setTaskId] = useState(undefined); - const handleCreate = async () => { try { const id = await scaffolderApi.scaffold(templateName, formState); - setTaskId(id); navigate(tasks({ taskId: id })); } catch (e) { errorApi.post(e); diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts index 20338463c3..f7b366be56 100644 --- a/plugins/scaffolder/src/components/hooks/useEventStream.ts +++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts @@ -14,13 +14,11 @@ * limitations under the License. */ import { useImmerReducer } from 'use-immer'; -import { useEffect, useState } from 'react'; -import { scaffolderApiRef } from '../../api'; -import { ScaffolderTask } from '../../types'; -import { useDebounce } from 'react-use'; +import { useEffect } from 'react'; +import { scaffolderApiRef, LogEvent } from '../../api'; +import { ScaffolderTask, Status } from '../../types'; import { Subscription, useApi } from '@backstage/core'; -export type Status = 'open' | 'processing' | 'failed' | 'completed'; type Step = { id: string; status: Status; @@ -37,18 +35,14 @@ export type TaskStream = { steps: { [stepId in string]: Step }; }; +type ReducerLogEntry = { + createdAt: string; + body: { stepId?: string; status?: Status; message: string }; +}; + type ReducerAction = - | { - type: 'INIT'; - data: ScaffolderTask; - } - | { - type: 'LOG'; - data: { - createdAt: string; - body: { stepId?: string; status?: Status; message: string }; - }; - } + | { type: 'INIT'; data: ScaffolderTask } + | { type: 'LOGS'; data: ReducerLogEntry[] } | { type: 'COMPLETED' } | { type: 'ERROR'; data: Error }; @@ -67,28 +61,35 @@ function reducer(draft: TaskStream, action: ReducerAction) { return; } - case 'LOG': { - const stepId = action.data.body.stepId ?? 'global'; - const currentStep = draft.steps?.[stepId]; - const logLine = `${action.data.createdAt} ${action.data.body.message}`; + case 'LOGS': { + const entries = action.data; + const logLines = []; + for (const entry of entries) { + const logLine = `${entry.createdAt} ${entry.body.message}`; + logLines.push(logLine); - draft.log.push(logLine); - - if ( - action.data.body.status && - action.data.body.status !== currentStep.status - ) { - currentStep.status = action.data.body.status; - - if (currentStep.status === 'processing') { - currentStep.startedAt = action.data.createdAt; + if (!entry.body.stepId || !draft.steps?.[entry.body.stepId]) { + continue; } - if (['cancelled', 'failed', 'completed'].includes(currentStep.status)) { - currentStep.endedAt = action.data.createdAt; + const currentStep = draft.steps?.[entry.body.stepId]; + + if (entry.body.status && entry.body.status !== currentStep.status) { + currentStep.status = entry.body.status; + + if (currentStep.status === 'processing') { + currentStep.startedAt = entry.createdAt; + } + + if ( + ['cancelled', 'failed', 'completed'].includes(currentStep.status) + ) { + currentStep.endedAt = entry.createdAt; + } } } + draft.log.push(...logLines); return; } @@ -117,17 +118,11 @@ export const useTaskEventStream = (taskId: string): TaskStream => { log: [], steps: {} as { [stepId in string]: Step }, }); - // const [debouncedState, setDebouncedState] = useState(state); - // useDebounce( - // () => { - // setDebouncedState(state); - // }, - // 1000, - // [state], - // ); + useEffect(() => { let didCancel = false; let subscription: Subscription | undefined; + let logPusher: NodeJS.Timeout | undefined; scaffolderApi.getTask(taskId).then( task => { @@ -136,19 +131,40 @@ export const useTaskEventStream = (taskId: string): TaskStream => { } dispatch({ type: 'INIT', data: task }); const observable = scaffolderApi.streamLogs({ taskId }); + + const collectedLogEvents = new Array(); + + function emitLogs() { + if (collectedLogEvents.length) { + const logs = collectedLogEvents.splice( + 0, + collectedLogEvents.length, + ); + dispatch({ type: 'LOGS', data: logs }); + } + } + + logPusher = setInterval(emitLogs, 500); + subscription = observable.subscribe({ next: event => { switch (event.type) { case 'log': - return dispatch({ type: 'LOG', data: event }); + return collectedLogEvents.push(event); default: throw new Error( `Unhandled event type ${event.type} in observer`, ); } }, - error: error => dispatch({ type: 'ERROR', data: error }), - complete: () => dispatch({ type: 'COMPLETED' }), + error: error => { + emitLogs(); + dispatch({ type: 'ERROR', data: error }); + }, + complete: () => { + emitLogs(); + dispatch({ type: 'COMPLETED' }); + }, }); }, error => { @@ -163,9 +179,11 @@ export const useTaskEventStream = (taskId: string): TaskStream => { if (subscription) { subscription.unsubscribe(); } + if (logPusher) { + clearInterval(logPusher); + } }; }, [scaffolderApi, dispatch, taskId]); return state; - // return debouncedState; }; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index fe1aec5286..0499f4aca2 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -15,6 +15,7 @@ */ import { JsonValue } from '@backstage/config'; +export type Status = 'open' | 'processing' | 'failed' | 'completed'; export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; export type Job = { id: string; From 2b15455d2b1a3f58b265257975cd1f065613a3bb Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 10 Feb 2021 16:16:56 +0100 Subject: [PATCH 10/58] Separate logs --- .../src/components/TaskPage/TaskPage.tsx | 129 +++++++++++------- .../src/components/hooks/useEventStream.ts | 21 ++- 2 files changed, 96 insertions(+), 54 deletions(-) diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index d6065fbd26..d9a6f86703 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -15,7 +15,7 @@ */ import { Page, Header, Lifecycle, Content } from '@backstage/core'; -import React, { useState, useEffect, memo } from 'react'; +import React, { useState, useEffect, memo, useMemo } from 'react'; import { makeStyles, Theme, @@ -62,52 +62,52 @@ const useStyles = makeStyles((theme: Theme) => ); type Steps = { - log: string[]; id: string; name: string; status: Status; }; -export const TaskStatusStepper = memo(({ steps }: { steps: Steps[] }) => { - const classes = useStyles(); - const [activeStep, setActiveStep] = useState(0); +export const TaskStatusStepper = memo( + ({ + steps, + currentStepId, + onUserStepChange, + }: { + steps: Steps[]; + currentStepId: string | undefined; + onUserStepChange: (id: string) => void; + }) => { + const classes = useStyles(); - const handleStep = (step: number) => { - setActiveStep(step); - }; - - useEffect(() => { - const activeIndex = steps.findIndex(step => - ['failed', 'processing'].includes(step.status), + return ( +
+ s.id === currentStepId)} + orientation="vertical" + nonLinear + > + {steps.map((step, index) => { + const isCompleted = step.status === 'completed'; + const isFailed = step.status === 'failed'; + return ( + + onUserStepChange(step.id)}> + + {step.name} + + + + ); + })} + +
); - setActiveStep(activeIndex); - }, [steps]); - - return ( -
- - {steps.map((step, index) => { - const isCompleted = step.status === 'completed'; - const isFailed = step.status === 'failed'; - return ( - - {}}> - - {step.name} - - - - ); - })} - -
- ); -}); + }, +); const TaskLogger = memo(({ log }: { log: string }) => { - console.log('rendering my log'); return (
@@ -125,13 +125,43 @@ const TaskActionsBar = () => { }; export const TaskPage = () => { + const [userSelectedStepId, setUserSelectedStepId] = useState< + string | undefined + >(undefined); + const [lastActiveStepId, setLastActiveStepId] = useState( + undefined, + ); const { taskId } = useParams(); const taskStream = useTaskEventStream(taskId); - const steps = - taskStream.task?.spec.steps.map(step => ({ - ...step, - ...taskStream?.steps?.[step.id], - })) ?? []; + + const steps = useMemo( + () => + taskStream.task?.spec.steps.map(step => ({ + ...step, + ...taskStream?.steps?.[step.id], + })) ?? [], + [taskStream], + ); + useEffect(() => { + const activeStep = steps.find(step => + ['failed', 'processing'].includes(step.status), + ); + setLastActiveStepId(activeStep?.id); + }, [steps]); + + const currentStepId = userSelectedStepId ?? lastActiveStepId; + + const logAsString = useMemo(() => { + if (!currentStepId) { + return 'Loading...'; + } + const log = taskStream.stepLogs[currentStepId]; + + if (!log?.length) { + return 'Waiting for logs...'; + } + return log.join('\n'); + }, [taskStream.stepLogs, currentStepId]); return ( @@ -145,17 +175,16 @@ export const TaskPage = () => { subtitle={`Activity for task: ${taskId}`} /> - - + - + diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts index f7b366be56..8bd8bb4d6c 100644 --- a/plugins/scaffolder/src/components/hooks/useEventStream.ts +++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts @@ -29,7 +29,7 @@ type Step = { export type TaskStream = { loading: boolean; error?: Error; - log: string[]; + stepLogs: { [stepId in string]: string[] }; completed: boolean; task?: ScaffolderTask; steps: { [stepId in string]: Step }; @@ -53,17 +53,21 @@ function reducer(draft: TaskStream, action: ReducerAction) { current[next.id] = { status: 'open', id: next.id }; return current; }, {} as { [stepId in string]: Step }); + draft.stepLogs = action.data.spec.steps.reduce((current, next) => { + current[next.id] = []; + return current; + }, {} as { [stepId in string]: string[] }); draft.loading = false; draft.error = undefined; draft.completed = false; draft.task = action.data; - draft.log = []; return; } case 'LOGS': { const entries = action.data; const logLines = []; + for (const entry of entries) { const logLine = `${entry.createdAt} ${entry.body.message}`; logLines.push(logLine); @@ -72,6 +76,7 @@ function reducer(draft: TaskStream, action: ReducerAction) { continue; } + const currentStepLog = draft.stepLogs?.[entry.body.stepId]; const currentStep = draft.steps?.[entry.body.stepId]; if (entry.body.status && entry.body.status !== currentStep.status) { @@ -87,9 +92,10 @@ function reducer(draft: TaskStream, action: ReducerAction) { currentStep.endedAt = entry.createdAt; } } + + currentStepLog?.push(logLine); } - draft.log.push(...logLines); return; } @@ -115,7 +121,7 @@ export const useTaskEventStream = (taskId: string): TaskStream => { const [state, dispatch] = useImmerReducer(reducer, { loading: true, completed: false, - log: [], + stepLogs: {} as { [stepId in string]: string[] }, steps: {} as { [stepId in string]: Step }, }); @@ -130,6 +136,13 @@ export const useTaskEventStream = (taskId: string): TaskStream => { return; } dispatch({ type: 'INIT', data: task }); + + // TODO(blam): Use a normal fetch to fetch the current log for the event stream + // and use that for an INIT_EVENTs dispatch event, and then + // use the last event ID to subscribe using after option to + // stream logs. Without this, if you have a lot of logs, it can look like the + // task is being rebuilt on load as it progresses through the steps at a slower + // rate whilst it builds the status from the event logs const observable = scaffolderApi.streamLogs({ taskId }); const collectedLogEvents = new Array(); From 3ed048216beeed78779cd831f213d8f06e7e4690 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 11 Feb 2021 09:19:03 +0100 Subject: [PATCH 11/58] Delete Jobstage component --- .../src/components/JobStage/JobStage.tsx | 173 ------------------ .../src/components/JobStage/LogModal.tsx | 67 ------- .../src/components/JobStage/index.ts | 16 -- 3 files changed, 256 deletions(-) delete mode 100644 plugins/scaffolder/src/components/JobStage/JobStage.tsx delete mode 100644 plugins/scaffolder/src/components/JobStage/LogModal.tsx delete mode 100644 plugins/scaffolder/src/components/JobStage/index.ts diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx deleted file mode 100644 index 1448fedf5c..0000000000 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ /dev/null @@ -1,173 +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 { - Accordion, - AccordionDetails, - AccordionSummary, - AccordionActions, - Box, - CircularProgress, - LinearProgress, - Typography, - Button, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import ExpandLessIcon from '@material-ui/icons/ExpandLess'; -import cn from 'classnames'; -import moment from 'moment'; -import React, { Suspense, useEffect, useState } from 'react'; -import { LogModal } from './LogModal'; -import { Job } from '../../types'; - -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); -moment.relativeTimeThreshold('ss', 0); - -const useStyles = makeStyles(theme => ({ - accordionDetails: { - padding: 0, - }, - button: { - order: -1, - margin: '0 1em 0 -20px', - }, - cardContent: { - backgroundColor: theme.palette.background.default, - }, - accordion: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - }, - }, - neutral: {}, - failed: { - '&:after': { - boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`, - }, - }, - started: { - '&:after': { - boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`, - }, - }, - completed: { - '&:after': { - boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`, - }, - }, - jobStatusTitle: { - display: 'flex', - width: '100%', - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - [theme.breakpoints.down('xs')]: { - flexDirection: 'column', - alignItems: 'flex-start', - justifyContent: 'flex-start', - }, - }, -})); - -type Props = { - name: string; - className?: string; - log: string[]; - startedAt: string; - endedAt?: string; - status: Job['status']; -}; - -export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { - const classes = useStyles(); - - const [expanded, setExpanded] = useState(false); - useEffect(() => { - if (status === 'failed') setExpanded(true); - }, [status, setExpanded]); - - const timeElapsed = - status === 'processing' - ? moment - .duration(moment(endedAt ?? moment()).diff(moment(startedAt))) - .humanize() - : null; - - const [logsFullScreen, setLogsFullScreen] = useState(false); - const toggleLogsFullScreen = () => setLogsFullScreen(!logsFullScreen); - - return ( - ] ?? - classes.neutral, - )} - expanded={expanded} - onChange={(_, newState) => setExpanded(newState)} - > - : } - aria-controls={`panel-${name}-content`} - id={`panel-${name}-header`} - IconButtonProps={{ - className: classes.button, - }} - > - - {name} {timeElapsed && `(${timeElapsed})`}{' '} - {startedAt && !endedAt && } - - - - {log.length === 0 ? ( -
- -
- ) : ( - }> - -
- -
-
- )} -
- - - -
- ); -}; diff --git a/plugins/scaffolder/src/components/JobStage/LogModal.tsx b/plugins/scaffolder/src/components/JobStage/LogModal.tsx deleted file mode 100644 index 73b6253510..0000000000 --- a/plugins/scaffolder/src/components/JobStage/LogModal.tsx +++ /dev/null @@ -1,67 +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 React from 'react'; -import { - Dialog, - DialogTitle, - DialogContent, - IconButton, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import Close from '@material-ui/icons/Close'; -import LazyLog from 'react-lazylog/build/LazyLog'; - -type Props = { - log: string[]; - open?: boolean; - onClose(): void; -}; - -const useStyles = makeStyles(theme => ({ - header: { - width: '100%', - padding: theme.spacing(1, 4), - }, - closeIcon: { - float: 'right', - padding: theme.spacing(0.5, 0), - }, - logs: { - boxShadow: '-3px -1px 7px 0px rgba(50, 50, 50, 0.59)', - height: '100%', - width: '100%', - }, -})); - -export const LogModal = ({ log, open = false, onClose }: Props) => { - const classes = useStyles(); - - return ( - - - Logs - - - - - -
- -
-
-
- ); -}; diff --git a/plugins/scaffolder/src/components/JobStage/index.ts b/plugins/scaffolder/src/components/JobStage/index.ts deleted file mode 100644 index d6d3534a88..0000000000 --- a/plugins/scaffolder/src/components/JobStage/index.ts +++ /dev/null @@ -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 { JobStage } from './JobStage'; From 8547d8d5ef1f153c342ad4a863f6b093ac6ad9b3 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 11 Feb 2021 09:25:03 +0100 Subject: [PATCH 12/58] cleanup imports --- .../src/components/TaskPage/TaskPage.tsx | 31 ++----------------- .../components/TemplatePage/TemplatePage.tsx | 3 +- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index d9a6f86703..2e7781bc92 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -16,30 +16,14 @@ import { Page, Header, Lifecycle, Content } from '@backstage/core'; import React, { useState, useEffect, memo, useMemo } from 'react'; -import { - makeStyles, - Theme, - createStyles, - withStyles, -} from '@material-ui/core/styles'; +import { makeStyles, Theme, createStyles } 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 Grid from '@material-ui/core/Grid'; -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, - Status, -} from '../hooks/useEventStream'; +import { useTaskEventStream } from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; import { StepButton, StepIconProps } from '@material-ui/core'; @@ -95,7 +79,7 @@ export const TaskStatusStepper = memo( - {step.name} + {step.name} @@ -115,15 +99,6 @@ const TaskLogger = memo(({ log }: { log: string }) => { ); }); -const TaskActionsBar = () => { - return ( - <> - - - - ); -}; - export const TaskPage = () => { const [userSelectedStepId, setUserSelectedStepId] = useState< string | undefined diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 85f3d955ac..7c25829e5e 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -29,12 +29,11 @@ import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useState } from 'react'; -import { Navigate } from 'react-router'; +import { Navigate, useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRoute, taskRoute } from '../../routes'; -import { useNavigate } from 'react-router'; import { MultistepJsonForm } from '../MultistepJsonForm'; const useTemplate = ( From 269f1eec60b954c0277e0ef3c08896a696602c84 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 11 Feb 2021 10:34:40 +0100 Subject: [PATCH 13/58] Fix task status info Co-authored-by: blam --- plugins/scaffolder/package.json | 3 + .../src/components/TaskPage/TaskPage.tsx | 64 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index b332c37c28..a97d339ba8 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -43,6 +43,8 @@ "classnames": "^2.2.6", "clsx": "^1.1.1", "git-url-parse": "^11.4.4", + "humanize-duration": "^3.25.1", + "luxon": "^1.25.0", "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -61,6 +63,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", + "@types/humanize-duration": "^3.18.1", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "cross-fetch": "^3.0.6", diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 2e7781bc92..01aa110582 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -26,6 +26,12 @@ import { useParams } from 'react-router'; import { useTaskEventStream } from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; import { StepButton, StepIconProps } from '@material-ui/core'; +import { Status } from '../../types'; +import { DateTime, Interval } from 'luxon'; +import { useInterval } from 'react-use'; + +// typings are wrong for this library, so fallback to not parsing types. +const humanizeDuration = require('humanize-duration'); const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -42,13 +48,48 @@ const useStyles = makeStyles((theme: Theme) => resetContainer: { padding: theme.spacing(3), }, + labelWrapper: { + display: 'flex', + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between', + }, + stepWrapper: { + width: '100%', + }, }), ); -type Steps = { +type TaskS = { id: string; name: string; status: Status; + startedAt?: string; + endedAt?: string; +}; + +const StepTimeTicker = ({ step }: { step: TaskS }) => { + const [time, setTime] = useState(''); + + useInterval(() => { + if (!step.startedAt) { + setTime(''); + return; + } + + const end = step.endedAt + ? DateTime.fromISO(step.endedAt) + : DateTime.local(); + + const startedAt = DateTime.fromISO(step.startedAt); + const formatted = Interval.fromDateTimes(startedAt, end) + .toDuration() + .valueOf(); + + setTime(humanizeDuration(formatted, { round: true })); + }, 1000); + + return {time}; }; export const TaskStatusStepper = memo( @@ -57,7 +98,7 @@ export const TaskStatusStepper = memo( currentStepId, onUserStepChange, }: { - steps: Steps[]; + steps: TaskS[]; currentStepId: string | undefined; onUserStepChange: (id: string) => void; }) => { @@ -78,8 +119,12 @@ export const TaskStatusStepper = memo( onUserStepChange(step.id)}> - {step.name} +
+ {step.name} + +
@@ -108,6 +153,7 @@ export const TaskPage = () => { ); const { taskId } = useParams(); const taskStream = useTaskEventStream(taskId); + const completed = taskStream.completed; const steps = useMemo( () => @@ -121,8 +167,14 @@ export const TaskPage = () => { const activeStep = steps.find(step => ['failed', 'processing'].includes(step.status), ); + + if (completed) { + setLastActiveStepId(steps[steps.length - 1]?.id); + return; + } + setLastActiveStepId(activeStep?.id); - }, [steps]); + }, [steps, completed]); const currentStepId = userSelectedStepId ?? lastActiveStepId; @@ -151,14 +203,14 @@ export const TaskPage = () => { /> - + - + From 499f85ba4fc2fe7322cfb30c8beff45e65b94f20 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 11 Feb 2021 10:36:47 +0100 Subject: [PATCH 14/58] Fix name --- plugins/scaffolder/src/components/TaskPage/TaskPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 01aa110582..240f0be189 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -60,7 +60,7 @@ const useStyles = makeStyles((theme: Theme) => }), ); -type TaskS = { +type TaskStep = { id: string; name: string; status: Status; @@ -68,7 +68,7 @@ type TaskS = { endedAt?: string; }; -const StepTimeTicker = ({ step }: { step: TaskS }) => { +const StepTimeTicker = ({ step }: { step: TaskStep }) => { const [time, setTime] = useState(''); useInterval(() => { @@ -98,7 +98,7 @@ export const TaskStatusStepper = memo( currentStepId, onUserStepChange, }: { - steps: TaskS[]; + steps: TaskStep[]; currentStepId: string | undefined; onUserStepChange: (id: string) => void; }) => { From c2025990084606c89c418938d5897f201a47dc82 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 11 Feb 2021 11:26:26 +0100 Subject: [PATCH 15/58] Add progress icons --- .../src/scaffolder/tasks/TemplateConverter.ts | 4 +- .../src/components/TaskPage/TaskPage.tsx | 66 +++++++++++++++++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts index 69788238cc..9c5df52c5e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -18,7 +18,7 @@ import { resolve as resolvePath } from 'path'; import { JsonValue } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; -import type { Writable } from 'stream'; +import { Writable } from 'stream'; import { TaskSpec } from './types'; import { ConflictError, NotFoundError } from '@backstage/backend-common'; @@ -69,7 +69,7 @@ export function templateEntityToSpec( steps.push({ id: 'publish', - name: 'Publishing', + name: 'Publish', action: 'legacy:publish', parameters: { values, diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 240f0be189..792e5ba3e3 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -25,10 +25,14 @@ import Typography from '@material-ui/core/Typography'; import { useParams } from 'react-router'; import { useTaskEventStream } from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; -import { StepButton, StepIconProps } from '@material-ui/core'; +import { CircularProgress, StepButton, StepIconProps } from '@material-ui/core'; import { Status } from '../../types'; import { DateTime, Interval } from 'luxon'; import { useInterval } from 'react-use'; +import clsx from 'clsx'; +import Check from '@material-ui/icons/Check'; +import Cancel from '@material-ui/icons/Cancel'; +import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -92,6 +96,54 @@ const StepTimeTicker = ({ step }: { step: TaskStep }) => { return {time}; }; +const useQontoStepIconStyles = makeStyles({ + root: { + color: '#eaeaf0', + display: 'flex', + height: 22, + alignItems: 'center', + }, + active: { + color: 'gray', + }, + completed: { + color: 'green', + }, + error: { + color: 'red', + }, +}); + +function TaskStepIconComponent(props: StepIconProps) { + const classes = useQontoStepIconStyles(); + const { active, completed, error } = props; + + const getMiddle = () => { + if (active) { + return ; + } + if (completed) { + return ; + } + if (error) { + return ; + } + return ; + }; + + return ( +
+ {getMiddle()} +
+ ); +} + export const TaskStatusStepper = memo( ({ steps, @@ -114,11 +166,17 @@ export const TaskStatusStepper = memo( {steps.map((step, index) => { const isCompleted = step.status === 'completed'; const isFailed = step.status === 'failed'; + const isActive = step.status === 'processing'; return ( onUserStepChange(step.id)}>
@@ -203,14 +261,14 @@ export const TaskPage = () => { /> - + - + From 5aa002fb201389e07986555088400914ebaaef93 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 11 Feb 2021 14:02:00 +0100 Subject: [PATCH 16/58] Add support for output templating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- plugins/scaffolder-backend/package.json | 5 +- .../src/scaffolder/tasks/StorageTaskBroker.ts | 10 +++- .../src/scaffolder/tasks/TaskWorker.ts | 50 ++++++++++++++++--- .../src/scaffolder/tasks/TemplateConverter.ts | 8 ++- .../src/scaffolder/tasks/types.ts | 3 +- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index ecce161c8e..252f498bd6 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -50,6 +50,7 @@ "fs-extra": "^9.0.0", "git-url-parse": "^11.4.4", "globby": "^11.0.0", + "handlebars": "^4.7.6", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", "jsonschema": "^1.2.6", @@ -66,9 +67,9 @@ "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", "mock-fs": "^4.13.0", + "msw": "^0.21.2", "supertest": "^4.0.2", - "yaml": "^1.10.0", - "msw": "^0.21.2" + "yaml": "^1.10.0" }, "files": [ "dist", diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index d3b2c098c7..fb1f4ab422 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -63,11 +63,17 @@ export class TaskAgent implements Task { }); } - async complete(result: CompletedTaskState): Promise { + async complete( + result: CompletedTaskState, + metadata?: JsonObject, + ): Promise { await this.storage.completeTask({ taskId: this.state.taskId, status: result === 'failed' ? 'failed' : 'completed', - eventBody: { message: `Run completed with status: ${result}` }, + eventBody: { + message: `Run completed with status: ${result}`, + ...metadata, + }, }); this.isDone = true; if (this.heartbeatTimeoutId) { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 66d1331c5c..51260aef7d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -22,6 +22,7 @@ import { TaskBroker, Task } from './types'; import fs from 'fs-extra'; import path from 'path'; import { TemplateActionRegistry } from './TemplateConverter'; +import * as handlebars from 'handlebars'; type Options = { logger: Logger; @@ -55,7 +56,11 @@ export class TaskWorker { `Starting up work with ${task.spec.steps.length} steps`, ); - const outputs: { [name: string]: JsonValue } = {}; + const templateCtx: { + steps: { + [stepName: string]: { output: { [outputName: string]: JsonValue } }; + }; + } = { steps: {} }; for (const step of task.spec.steps) { const metadata = { stepId: step.id }; @@ -87,8 +92,24 @@ export class TaskWorker { throw new Error(`Action '${step.action}' does not exist`); } - // TODO: substitute any placeholders with output from previous steps - const parameters = step.parameters!; + const parameters: { [name: string]: JsonValue } = {}; + for (const [name, maybeTemplateStr] of Object.entries( + step.parameters ?? {}, + )) { + if (typeof maybeTemplateStr === 'string') { + const value = handlebars.compile(maybeTemplateStr, { + noEscape: true, + strict: true, + data: false, + preventIndent: true, + })(templateCtx); + parameters[name] = value; + } else { + parameters[name] = maybeTemplateStr; + } + } + + const stepOutputs: { [name: string]: JsonValue } = {}; await action.handler({ logger, @@ -96,10 +117,12 @@ export class TaskWorker { parameters, workspacePath, output(name: string, value: JsonValue) { - outputs[name] = value; + stepOutputs[name] = value; }, }); + templateCtx.steps[step.id] = { output: stepOutputs }; + await task.emitLog(`Finished step ${step.name}`, { ...metadata, status: 'completed', @@ -112,9 +135,24 @@ export class TaskWorker { throw error; } } - await task.complete('completed'); + + const output = Object.fromEntries( + Object.entries(task.spec.output).map(([name, templateStr]) => { + const value = handlebars.compile(templateStr, { + noEscape: true, + strict: true, + data: false, + preventIndent: true, + })(templateCtx); + return [name, value]; + }), + ); + + await task.complete('completed', { output }); } catch (error) { - await task.complete('failed'); + await task.complete('failed', { + error: { name: error.name, message: error.message }, + }); } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts index 9c5df52c5e..48f31f73f2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -76,7 +76,13 @@ export function templateEntityToSpec( }, }); - return { steps }; + return { + steps, + output: { + remoteUrl: '{{ steps.publish.output.remoteUrl }}', + catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}', + }, + }; } type ActionContext = { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 783528dbdf..ae18a59e41 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -49,6 +49,7 @@ export type TaskSpec = { action: string; parameters?: { [name: string]: JsonValue }; }>; + output: { [name: string]: string }; }; export type DispatchResult = { @@ -59,7 +60,7 @@ export interface Task { spec: TaskSpec; done: boolean; emitLog(message: string, metadata?: JsonValue): Promise; - complete(result: CompletedTaskState): Promise; + complete(result: CompletedTaskState, metadata?: JsonValue): Promise; getWorkspaceName(): Promise; } From 1ae69b890a9c051629e039615dc28bd867b682c4 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 12 Feb 2021 08:38:20 +0100 Subject: [PATCH 17/58] scaffolder: Add registration step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- packages/backend/src/plugins/scaffolder.ts | 3 ++ .../src/scaffolder/stages/legacy.ts | 29 ++++++++++++++++++- .../src/scaffolder/tasks/TemplateConverter.ts | 9 ++++++ .../scaffolder-backend/src/service/router.ts | 6 ++-- plugins/scaffolder/package.json | 4 +-- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 723165ff82..ae4b22759b 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -26,6 +26,7 @@ import { import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; +import { CatalogClient } from '@backstage/catalog-client'; export default async function createPlugin({ logger, @@ -46,6 +47,7 @@ export default async function createPlugin({ const discovery = SingleHostDiscovery.fromConfig(config); const entityClient = new CatalogEntityClient({ discovery }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ preparers, @@ -56,5 +58,6 @@ export default async function createPlugin({ dockerClient, entityClient, database, + catalogClient, }); } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts index f151780ba9..5cead565f1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts @@ -19,19 +19,28 @@ import { FilePreparer, PreparerBuilder } from './prepare'; import Docker from 'dockerode'; import { TemplaterBuilder, TemplaterValues } from './templater'; import { PublisherBuilder } from './publish'; +import { CatalogApi } from '@backstage/catalog-client'; +import { getEntityName } from '@backstage/catalog-model'; type Options = { dockerClient: Docker; preparers: PreparerBuilder; templaters: TemplaterBuilder; publishers: PublisherBuilder; + catalogClient: CatalogApi; }; export function registerLegacyActions( registry: TemplateActionRegistry, options: Options, ) { - const { dockerClient, preparers, templaters, publishers } = options; + const { + dockerClient, + preparers, + templaters, + publishers, + catalogClient, + } = options; registry.register({ id: 'legacy:prepare', @@ -107,4 +116,22 @@ export function registerLegacyActions( } }, }); + + registry.register({ + id: 'catalog:register', + async handler(ctx) { + const { logger } = ctx; + const { catalogInfoUrl } = ctx.parameters; // TODO update schema + + logger.info(`Registering ${catalogInfoUrl} in the catalog`); + const result = await catalogClient.addLocation({ + type: 'url', + target: catalogInfoUrl as string, + }); + if (result.entities.length === 1) { + const { kind, name, namespace } = getEntityName(result.entities[0]); + ctx.output('entityRef', `${kind}:${namespace}/${name}`); + } + }, + }); } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts index 48f31f73f2..20e8886884 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -76,6 +76,15 @@ export function templateEntityToSpec( }, }); + steps.push({ + id: 'register', + name: 'Register', + action: 'catalog:register', + parameters: { + catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}', + }, + }); + return { steps, output: { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 959e9e42c5..26be55d898 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -48,6 +48,7 @@ import { NotFoundError, PluginDatabaseManager, } from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; export interface RouterOptions { preparers: PreparerBuilder; @@ -59,6 +60,7 @@ export interface RouterOptions { dockerClient: Docker; entityClient: CatalogEntityClient; database: PluginDatabaseManager; + catalogClient: CatalogClient; } export async function createRouter( @@ -76,6 +78,7 @@ export async function createRouter( dockerClient, entityClient, database, + catalogClient, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); @@ -99,6 +102,7 @@ export async function createRouter( preparers, publishers, templaters, + catalogClient, }); worker.start(); @@ -254,9 +258,7 @@ export async function createRouter( }) .get('/v2/tasks/:taskId', async (req, res) => { const { taskId } = req.params; - console.warn('getting task'); const task = await taskBroker.get(taskId); - console.warn('got task', task); if (!task) { throw new NotFoundError(`task with id ${taskId} does not exist`); } diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 6f55d0114e..f602f01b5b 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -30,9 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.1", "@backstage/config": "^0.1.2", + "@backstage/core": "^0.6.1", "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -60,7 +61,6 @@ "@backstage/cli": "^0.6.0", "@backstage/dev-utils": "^0.1.10", "@backstage/test-utils": "^0.1.7", - "@backstage/catalog-client": "^0.3.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", From d4c77f931654e7193b3d6662e1df99d428a6b773 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 12 Feb 2021 09:46:33 +0100 Subject: [PATCH 18/58] Scaffolder: Initial 404 view --- .../src/components/TaskPage/TaskPage.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 792e5ba3e3..e2a19f9f63 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -248,6 +248,11 @@ export const TaskPage = () => { return log.join('\n'); }, [taskStream.stepLogs, currentStepId]); + const taskNotFound = + taskStream.completed === true && + taskStream.loading === false && + !taskStream.task; + return (
{ subtitle={`Activity for task: ${taskId}`} /> - - - + {taskNotFound ? ( +
Task not found
+ ) : ( + + + + + + + - - - -
+ )}
); From 087bee17446d8d0e1750d6ccdc58a3e3a517ad9f Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 12 Feb 2021 09:46:59 +0100 Subject: [PATCH 19/58] scaffolder: Pass correct logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- .../scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 51260aef7d..4511a836f6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -76,9 +76,11 @@ export class TaskWorker { }); const stream = new PassThrough(); - stream.on('data', data => { + stream.on('data', async data => { const message = data.toString().trim(); - if (message?.length > 1) task.emitLog(message, metadata); + if (message?.length > 1) { + await task.emitLog(message, metadata); + } }); taskLogger.add(new winston.transports.Stream({ stream })); @@ -112,7 +114,7 @@ export class TaskWorker { const stepOutputs: { [name: string]: JsonValue } = {}; await action.handler({ - logger, + logger: taskLogger, logStream: stream, parameters, workspacePath, From 9fe687aa37a70a8477294e7475c066fccd78de18 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 12 Feb 2021 15:11:25 +0100 Subject: [PATCH 20/58] scaffolder: fix route name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- plugins/scaffolder/src/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 6b55f89ff1..08f9f3bfa3 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -26,7 +26,7 @@ export const templateRoute = createRouteRef({ }); export const taskRoute = createRouteRef({ - path: '/scaffolder/task/:taskId', + path: '/scaffolder/tasks/:taskId', title: 'Task information', params: ['taskId'], }); From 73760c688f211ff4d1932d5b62262374a93ef637 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 12 Feb 2021 15:13:08 +0100 Subject: [PATCH 21/58] Scaffolder: display entity button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam --- .../src/scaffolder/stages/legacy.ts | 14 ++--- .../src/scaffolder/tasks/TaskWorker.ts | 2 +- .../src/scaffolder/tasks/TemplateConverter.ts | 1 + .../src/components/TaskPage/TaskPage.tsx | 58 +++++++++++++------ .../src/components/hooks/useEventStream.ts | 21 +++++-- 5 files changed, 62 insertions(+), 34 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts index 5cead565f1..efba57a063 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts @@ -45,11 +45,11 @@ export function registerLegacyActions( registry.register({ id: 'legacy:prepare', async handler(ctx) { + ctx.logger.info('Preparing the skeleton'); const { protocol, url } = ctx.parameters; const preparer = protocol === 'file' ? new FilePreparer() : preparers.get(url as string); - ctx.logger.info('Prepare the skeleton'); await preparer.prepare({ url: url as string, logger: ctx.logger, @@ -61,11 +61,8 @@ export function registerLegacyActions( registry.register({ id: 'legacy:template', async handler(ctx) { - const { logger } = ctx; - + ctx.logger.info('Running the templater'); const templater = templaters.get(ctx.parameters.templater as string); - - logger.info('Run the templater'); await templater.run({ workspacePath: ctx.workspacePath, dockerClient, @@ -120,15 +117,14 @@ export function registerLegacyActions( registry.register({ id: 'catalog:register', async handler(ctx) { - const { logger } = ctx; - const { catalogInfoUrl } = ctx.parameters; // TODO update schema + const { catalogInfoUrl } = ctx.parameters; + ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`); - logger.info(`Registering ${catalogInfoUrl} in the catalog`); const result = await catalogClient.addLocation({ type: 'url', target: catalogInfoUrl as string, }); - if (result.entities.length === 1) { + if (result.entities.length >= 1) { const { kind, name, namespace } = getEntityName(result.entities[0]); ctx.output('entityRef', `${kind}:${namespace}/${name}`); } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 4511a836f6..6eac36b3f3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -45,7 +45,7 @@ export class TaskWorker { async runOneTask(task: Task) { try { - const { actionRegistry, logger } = this.options; + const { actionRegistry } = this.options; const workspacePath = path.join( this.options.workingDirectory, diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts index 20e8886884..4139948513 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -90,6 +90,7 @@ export function templateEntityToSpec( output: { remoteUrl: '{{ steps.publish.output.remoteUrl }}', catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}', + entityRef: '{{ steps.register.output.entityRef }}', }, }; } diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index e2a19f9f63..40a52d8b93 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -25,7 +25,14 @@ import Typography from '@material-ui/core/Typography'; import { useParams } from 'react-router'; import { useTaskEventStream } from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; -import { CircularProgress, StepButton, StepIconProps } from '@material-ui/core'; +import { + Box, + Button, + CircularProgress, + Paper, + StepButton, + StepIconProps, +} from '@material-ui/core'; import { Status } from '../../types'; import { DateTime, Interval } from 'luxon'; import { useInterval } from 'react-use'; @@ -33,6 +40,8 @@ import clsx from 'clsx'; import Check from '@material-ui/icons/Check'; import Cancel from '@material-ui/icons/Cancel'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; +import { parseEntityName } from '@backstage/catalog-model'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -197,7 +206,7 @@ export const TaskStatusStepper = memo( const TaskLogger = memo(({ log }: { log: string }) => { return (
- +
); }); @@ -212,7 +221,6 @@ export const TaskPage = () => { const { taskId } = useParams(); const taskStream = useTaskEventStream(taskId); const completed = taskStream.completed; - const steps = useMemo( () => taskStream.task?.spec.steps.map(step => ({ @@ -221,17 +229,17 @@ export const TaskPage = () => { })) ?? [], [taskStream], ); + useEffect(() => { - const activeStep = steps.find(step => + const mostRecentFailedOrActiveStep = steps.find(step => ['failed', 'processing'].includes(step.status), ); - - if (completed) { + if (completed && !mostRecentFailedOrActiveStep) { setLastActiveStepId(steps[steps.length - 1]?.id); return; } - setLastActiveStepId(activeStep?.id); + setLastActiveStepId(mostRecentFailedOrActiveStep?.id); }, [steps, completed]); const currentStepId = userSelectedStepId ?? lastActiveStepId; @@ -253,6 +261,7 @@ export const TaskPage = () => { taskStream.loading === false && !taskStream.task; + const entityRef = taskStream.output?.entityRef; return (
{ {taskNotFound ? (
Task not found
) : ( - - - +
+ + + + + {entityRef && ( + + + + )} + + + + + - - - - +
)} diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts index 8bd8bb4d6c..210fd8cb9b 100644 --- a/plugins/scaffolder/src/components/hooks/useEventStream.ts +++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts @@ -26,6 +26,8 @@ type Step = { startedAt?: string; }; +type TaskOutput = { entityRef?: string } & { [key in string]: string }; + export type TaskStream = { loading: boolean; error?: Error; @@ -33,17 +35,23 @@ export type TaskStream = { completed: boolean; task?: ScaffolderTask; steps: { [stepId in string]: Step }; + output?: TaskOutput; }; type ReducerLogEntry = { createdAt: string; - body: { stepId?: string; status?: Status; message: string }; + body: { + stepId?: string; + status?: Status; + message: string; + output?: TaskOutput; + }; }; type ReducerAction = | { type: 'INIT'; data: ScaffolderTask } | { type: 'LOGS'; data: ReducerLogEntry[] } - | { type: 'COMPLETED' } + | { type: 'COMPLETED'; data: ReducerLogEntry } | { type: 'ERROR'; data: Error }; function reducer(draft: TaskStream, action: ReducerAction) { @@ -101,6 +109,7 @@ function reducer(draft: TaskStream, action: ReducerAction) { case 'COMPLETED': { draft.completed = true; + draft.output = action.data.body.output; return; } @@ -164,6 +173,10 @@ export const useTaskEventStream = (taskId: string): TaskStream => { switch (event.type) { case 'log': return collectedLogEvents.push(event); + case 'completion': + emitLogs(); + dispatch({ type: 'COMPLETED', data: event }); + return undefined; default: throw new Error( `Unhandled event type ${event.type} in observer`, @@ -174,10 +187,6 @@ export const useTaskEventStream = (taskId: string): TaskStream => { emitLogs(); dispatch({ type: 'ERROR', data: error }); }, - complete: () => { - emitLogs(); - dispatch({ type: 'COMPLETED' }); - }, }); }, error => { From 028603219f7a448203d641e1e57f9f509abeb8d6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 12 Feb 2021 16:07:39 +0100 Subject: [PATCH 22/58] Add task not found page Co-authored-by: blam --- .../components/TaskNotFound/TaskNotFound.tsx | 59 +++++++++++++++++++ .../src/components/TaskPage/TaskPage.tsx | 45 +++++++++++--- 2 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx diff --git a/plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx b/plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx new file mode 100644 index 0000000000..24ac8c357d --- /dev/null +++ b/plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx @@ -0,0 +1,59 @@ +/* + * 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 from 'react'; +import { Grid, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles(theme => ({ + container: { + paddingTop: theme.spacing(24), + paddingLeft: theme.spacing(8), + [theme.breakpoints.down('xs')]: { + padding: theme.spacing(2), + }, + }, + title: { + paddingBottom: theme.spacing(2), + [theme.breakpoints.down('xs')]: { + fontSize: 32, + }, + }, + body: { + paddingBottom: theme.spacing(6), + [theme.breakpoints.down('xs')]: { + paddingBottom: theme.spacing(5), + }, + }, +})); + +export const TaskNotFound = () => { + const classes = useStyles(); + + return ( + + + + Task not found + + + No task found with this ID + + + + ); +}; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 40a52d8b93..8c9b63fa1b 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -22,9 +22,10 @@ import Step from '@material-ui/core/Step'; import StepLabel from '@material-ui/core/StepLabel'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; -import { useParams } from 'react-router'; +import { generatePath, useParams } from 'react-router'; import { useTaskEventStream } from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; +import { Link } from 'react-router-dom'; import { Box, Button, @@ -40,8 +41,9 @@ import clsx from 'clsx'; import Check from '@material-ui/icons/Check'; import Cancel from '@material-ui/icons/Cancel'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; -import { EntityRefLink } from '@backstage/plugin-catalog-react'; +import { entityRoute } from '@backstage/plugin-catalog-react'; import { parseEntityName } from '@backstage/catalog-model'; +import { TaskNotFound } from '../TaskNotFound/TaskNotFound'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -262,6 +264,7 @@ export const TaskPage = () => { !taskStream.task; const entityRef = taskStream.output?.entityRef; + const remoteUrl = taskStream.output?.remoteUrl; return (
{ /> {taskNotFound ? ( -
Task not found
+ ) : (
@@ -286,13 +289,37 @@ export const TaskPage = () => { currentStepId={currentStepId} onUserStepChange={setUserSelectedStepId} /> - {entityRef && ( - - + + )} + {remoteUrl && ( + + )} )} From 07b996ed3d5807978f39c8904d1bfa488a593115 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 09:19:55 +0100 Subject: [PATCH 23/58] Rename styles --- plugins/scaffolder/src/components/TaskPage/TaskPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 8c9b63fa1b..fbf2a09a21 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -107,7 +107,7 @@ const StepTimeTicker = ({ step }: { step: TaskStep }) => { return {time}; }; -const useQontoStepIconStyles = makeStyles({ +const useStepIconStyles = makeStyles({ root: { color: '#eaeaf0', display: 'flex', @@ -126,7 +126,7 @@ const useQontoStepIconStyles = makeStyles({ }); function TaskStepIconComponent(props: StepIconProps) { - const classes = useQontoStepIconStyles(); + const classes = useStepIconStyles(); const { active, completed, error } = props; const getMiddle = () => { From c649761548b931d0db222b6f81a9dfbbf4c53ddf Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 09:20:12 +0100 Subject: [PATCH 24/58] Drop unused import --- plugins/scaffolder/src/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 36bac5875c..4978f7fafa 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { JsonObject } from '@backstage/config'; import { createApiRef, DiscoveryApi, From 1f077511bb074069ac0ba68c445c624d413a2c87 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 09:30:13 +0100 Subject: [PATCH 25/58] Fix typescript warnings --- .../src/scaffolder/tasks/StorageTaskBroker.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts index ffb8de6e3d..fd2de30332 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts @@ -47,7 +47,7 @@ describe('StorageTaskBroker', () => { const logger = getVoidLogger(); it('should claim a dispatched work item', async () => { const broker = new StorageTaskBroker(storage, logger); - await broker.dispatch({ steps: [] }); + await broker.dispatch({} as TaskSpec); await expect(broker.claim()).resolves.toEqual(expect.any(TaskAgent)); }); @@ -57,7 +57,7 @@ describe('StorageTaskBroker', () => { await expect(Promise.race([promise, 'waiting'])).resolves.toBe('waiting'); - await broker.dispatch({ steps: [] }); + await broker.dispatch({} as TaskSpec); await expect(promise).resolves.toEqual(expect.any(TaskAgent)); }); @@ -80,7 +80,7 @@ describe('StorageTaskBroker', () => { it('should complete a task', async () => { const broker = new StorageTaskBroker(storage, logger); - const dispatchResult = await broker.dispatch({ steps: [] }); + const dispatchResult = await broker.dispatch({} as TaskSpec); const task = await broker.claim(); await task.complete('completed'); const taskRow = await storage.getTask(dispatchResult.taskId); @@ -89,7 +89,7 @@ describe('StorageTaskBroker', () => { it('should fail a task', async () => { const broker = new StorageTaskBroker(storage, logger); - const dispatchResult = await broker.dispatch({ steps: [] }); + const dispatchResult = await broker.dispatch({} as TaskSpec); const task = await broker.claim(); await task.complete('failed'); const taskRow = await storage.getTask(dispatchResult.taskId); @@ -100,7 +100,7 @@ describe('StorageTaskBroker', () => { const broker1 = new StorageTaskBroker(storage, logger); const broker2 = new StorageTaskBroker(storage, logger); - const { taskId } = await broker1.dispatch({ steps: [] }); + const { taskId } = await broker1.dispatch({} as TaskSpec); const logPromise = new Promise(resolve => { const observedEvents = new Array(); @@ -139,7 +139,7 @@ describe('StorageTaskBroker', () => { it('should heartbeat', async () => { const broker = new StorageTaskBroker(storage, logger); - const { taskId } = await broker.dispatch({ steps: [] }); + const { taskId } = await broker.dispatch({} as TaskSpec); const task = await broker.claim(); const initialTask = await storage.getTask(taskId); @@ -157,7 +157,7 @@ describe('StorageTaskBroker', () => { it('should be update the status to failed if heartbeat fails', async () => { const broker = new StorageTaskBroker(storage, logger); - const { taskId } = await broker.dispatch({ steps: [] }); + const { taskId } = await broker.dispatch({} as TaskSpec); const task = await broker.claim(); jest From 93765c04d8504130932ed13a4e9724a6b23c2e10 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 09:53:50 +0100 Subject: [PATCH 26/58] mock catalogClient --- plugins/scaffolder-backend/src/service/router.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 8a0fa6cba6..b5de493680 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -39,6 +39,10 @@ import request from 'supertest'; import { createRouter } from './router'; import { Templaters, Preparers, Publishers } from '../scaffolder'; import Docker from 'dockerode'; +import { CatalogClient } from '@backstage/catalog-client'; + +jest.mock('@backstage/catalog-client'); +const MockedCatalogClient = CatalogClient as jest.Mock; jest.mock('dockerode'); @@ -111,6 +115,7 @@ describe('createRouter - working directory', () => { dockerClient: new Docker(), entityClient: mockedEntityClient, database: createDatabase(), + catalogClient: new MockedCatalogClient(), }), ).rejects.toThrow('access error'); }); @@ -125,6 +130,7 @@ describe('createRouter - working directory', () => { dockerClient: new Docker(), entityClient: mockedEntityClient, database: createDatabase(), + catalogClient: new MockedCatalogClient(), }); const app = express().use(router); @@ -154,6 +160,7 @@ describe('createRouter - working directory', () => { dockerClient: new Docker(), entityClient: mockedEntityClient, database: createDatabase(), + catalogClient: new MockedCatalogClient(), }); const app = express().use(router); @@ -224,6 +231,7 @@ describe('createRouter', () => { dockerClient: new Docker(), entityClient: generateEntityClient(template), database: createDatabase(), + catalogClient: new MockedCatalogClient(), }); app = express().use(router); }); From 05487b0e693c3c8068cd5203bd56dbdadb973e48 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 11:12:53 +0100 Subject: [PATCH 27/58] Export createExternalRouteRef and set id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- docs/plugins/composability.md | 2 +- packages/core-api/src/app/App.test.tsx | 8 ++++---- packages/core-api/src/routing/RouteRef.ts | 21 ++++++++++++++------ packages/core-api/src/routing/hooks.test.tsx | 6 +++--- packages/core-api/src/routing/hooks.tsx | 2 +- packages/core-api/src/routing/index.ts | 2 +- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md index a496217875..6106fa9141 100644 --- a/docs/plugins/composability.md +++ b/docs/plugins/composability.md @@ -248,7 +248,7 @@ might be linking to, allowing the app to decide the final target. If the declare an `ExternalRouteRef` similar to this: ```ts -const headerLinkRouteRef = createExternalRouteRef(); +const headerLinkRouteRef = createExternalRouteRef({ id: 'header-link' }); ``` ### Binding External Routes in the App diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx index 8dd40d52b0..6a1e80334b 100644 --- a/packages/core-api/src/app/App.test.tsx +++ b/packages/core-api/src/app/App.test.tsx @@ -28,7 +28,7 @@ import { generateBoundRoutes, PrivateAppImpl } from './App'; describe('generateBoundRoutes', () => { it('runs happy path', () => { - const external = { myRoute: createExternalRouteRef() }; + const external = { myRoute: createExternalRouteRef({ id: '1' }) }; const ref = createRouteRef({ path: '', title: '' }); const result = generateBoundRoutes(({ bind }) => { bind(external, { myRoute: ref }); @@ -38,7 +38,7 @@ describe('generateBoundRoutes', () => { }); it('throws on unknown keys', () => { - const external = { myRoute: createExternalRouteRef() }; + const external = { myRoute: createExternalRouteRef({ id: '2' }) }; const ref = createRouteRef({ path: '', title: '' }); expect(() => generateBoundRoutes(({ bind }) => { @@ -51,7 +51,7 @@ describe('generateBoundRoutes', () => { describe('Integration Test', () => { const plugin1RouteRef = createRouteRef({ path: '/blah1', title: '' }); const plugin2RouteRef = createRouteRef({ path: '/blah2', title: '' }); - const externalRouteRef = createExternalRouteRef(); + const externalRouteRef = createExternalRouteRef({ id: '3' }); const plugin1 = createPlugin({ id: 'blob', @@ -77,7 +77,7 @@ describe('Integration Test', () => { Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { // eslint-disable-next-line react-hooks/rules-of-hooks const routeRefFunction = useRouteRef(externalRouteRef); - return
Our Route Is: {routeRefFunction({})}
; + return
Our Route Is: {routeRefFunction()}
; }), mountPoint: plugin1RouteRef, }), diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 0a3df49b58..5ae85ab553 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -65,13 +65,22 @@ export function createRouteRef< } export class ExternalRouteRef { - private constructor() {} - - toString() { - return `externalRouteRef{}`; + private constructor(id: string) { + this.toString = () => `externalRouteRef{${id}}`; } } -export function createExternalRouteRef(): ExternalRouteRef { - return new ((ExternalRouteRef as unknown) as { new (): ExternalRouteRef })(); +export type ExternalRouteRefOptions = { + /** + * An identifier for this route, used to identify it in error messages + */ + id: string; +}; + +export function createExternalRouteRef( + options: ExternalRouteRefOptions, +): ExternalRouteRef { + return new ((ExternalRouteRef as unknown) as { + new (id: string): ExternalRouteRef; + })(options.id); } diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 84a8314807..462b44c3bd 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -59,9 +59,9 @@ const ref2 = createRouteRef(mockConfig({ path: '/wat2' })); const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); const ref5 = createRouteRef(mockConfig({ path: '/wat5' })); -const eRefA = createExternalRouteRef(); -const eRefB = createExternalRouteRef(); -const eRefC = createExternalRouteRef(); +const eRefA = createExternalRouteRef({ id: '1' }); +const eRefB = createExternalRouteRef({ id: '2' }); +const eRefC = createExternalRouteRef({ id: '3' }); const MockRouteSource = (props: { path?: string; diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 3a18d8b0af..c478027ae2 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -111,7 +111,7 @@ class RouteResolver { const RoutingContext = createContext(undefined); -export function useRouteRef( +export function useRouteRef( routeRef: RouteRef | ExternalRouteRef, ): RouteFunc { const sourceLocation = useLocation(); diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index af71e0c3e9..ad88f8ff02 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -21,6 +21,6 @@ export type { MutableRouteRef, } from './types'; export { FlatRoutes } from './FlatRoutes'; -export { createRouteRef } from './RouteRef'; +export { createRouteRef, createExternalRouteRef } from './RouteRef'; export type { RouteRefConfig } from './RouteRef'; export { useRouteRef } from './hooks'; From 579a5456498862d4ddd1c3590dcc5dea677a3870 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 11:57:27 +0100 Subject: [PATCH 28/58] Refactor scaffolder routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- packages/app/src/App.tsx | 19 +++++++++++++- packages/core-api/src/app/types.ts | 2 +- plugins/catalog/package.json | 1 - .../components/CatalogPage/CatalogPage.tsx | 7 ++--- plugins/catalog/src/plugin.ts | 4 +++ plugins/catalog/src/routes.ts | 21 +++++++++++++++ .../components/TemplateCard/TemplateCard.tsx | 9 +++---- .../TemplatePage/TemplatePage.test.tsx | 3 +-- .../components/TemplatePage/TemplatePage.tsx | 15 ++++++----- plugins/scaffolder/src/index.ts | 1 - plugins/scaffolder/src/plugin.ts | 26 ++++++++----------- plugins/scaffolder/src/routes.ts | 10 +++---- 12 files changed, 77 insertions(+), 41 deletions(-) create mode 100644 plugins/catalog/src/routes.ts diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 70fceb8a01..d0f248c78b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -22,12 +22,21 @@ import { OAuthRequestDialog, SignInPage, } from '@backstage/core'; -import { Router as CatalogRouter } from '@backstage/plugin-catalog'; +import { + catalogPlugin, + Router as CatalogRouter, +} from '@backstage/plugin-catalog'; import { CatalogImportPage } from '@backstage/plugin-catalog-import'; import { ExplorePage } from '@backstage/plugin-explore'; import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; +import { + TemplateIndexPage, + TemplatePage, + TaskPage, + scaffolderPlugin, +} from '@backstage/plugin-scaffolder'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; @@ -55,6 +64,11 @@ const app = createApp({ ); }, }, + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.templateIndex, + }); + }, }); const AppProvider = app.getProvider(); @@ -75,6 +89,9 @@ const routes = ( element={} /> } /> + } /> + } /> + } /> } /> { CatalogFilterType >(); const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; - + const createComponentLink = useRouteRef(createComponentRouteRef); const addMockData = useCallback(async () => { try { const promises: Promise[] = []; @@ -166,7 +167,7 @@ const CatalogPageContents = () => { component={RouterLink} variant="contained" color="primary" - to={scaffolderRootRoute.path} + to={createComponentLink()} > Create Component diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index c6944a318a..a2d7fe3c31 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -29,6 +29,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { CatalogClientWrapper } from './CatalogClientWrapper'; +import { createComponentRouteRef } from './routes'; export const catalogPlugin = createPlugin({ id: 'catalog', @@ -47,6 +48,9 @@ export const catalogPlugin = createPlugin({ catalogIndex: catalogRouteRef, catalogEntity: entityRouteRef, }, + externalRoutes: { + createComponent: createComponentRouteRef, + }, }); export const CatalogIndexPage = catalogPlugin.provide( diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts new file mode 100644 index 0000000000..40e1784235 --- /dev/null +++ b/plugins/catalog/src/routes.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 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 { createExternalRouteRef } from '@backstage/core'; + +export const createComponentRouteRef = createExternalRouteRef({ + id: 'create-component', +}); diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 732b5f3e44..328da7e371 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button } from '@backstage/core'; +import { Button, useRouteRef } from '@backstage/core'; import { BackstageTheme, pageTheme } from '@backstage/theme'; import { Card, @@ -23,8 +23,7 @@ import { useTheme, } from '@material-ui/core'; import React from 'react'; -import { generatePath } from 'react-router-dom'; -import { templateRoute } from '../../routes'; +import { templateRouteRef } from '../../routes'; const useStyles = makeStyles(theme => ({ header: { @@ -68,7 +67,7 @@ export const TemplateCard = ({ const themeId = pageTheme[type] ? type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); - const href = generatePath(templateRoute.path, { templateName: name }); + const templateLink = useRouteRef(templateRouteRef); return ( @@ -84,7 +83,7 @@ export const TemplateCard = ({ {description}
-
diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index f3f97be877..c5c8367994 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -22,7 +22,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { MemoryRouter, Route } from 'react-router'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; -import { rootRoute } from '../../routes'; import { TemplatePage } from './TemplatePage'; const templateMock = { @@ -134,7 +133,7 @@ describe('TemplatePage', () => { - This is root} /> + This is root} /> , diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 7c25829e5e..ad19af2c7c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -29,11 +29,11 @@ import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useState } from 'react'; -import { Navigate, useNavigate } from 'react-router'; +import { useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; -import { rootRoute, taskRoute } from '../../routes'; +import { taskRouteRef, templateIndexRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; const useTemplate = ( @@ -78,7 +78,8 @@ export const TemplatePage = () => { const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); const navigate = useNavigate(); - const tasks = useRouteRef(taskRoute); + const tasksLink = useRouteRef(taskRouteRef); + const templateIndexLink = useRouteRef(templateIndexRouteRef); const { template, loading } = useTemplate(templateName, catalogApi); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); @@ -91,7 +92,7 @@ export const TemplatePage = () => { const handleCreate = async () => { try { const id = await scaffolderApi.scaffold(templateName, formState); - navigate(tasks({ taskId: id })); + navigate(tasksLink({ taskId: id })); } catch (e) { errorApi.post(e); } @@ -99,7 +100,8 @@ export const TemplatePage = () => { if (!loading && !template) { errorApi.post(new Error('Template was not found.')); - return ; + navigate(templateIndexLink()); + return <>{null}; } if (template && !template?.spec?.schema) { @@ -108,7 +110,8 @@ export const TemplatePage = () => { 'Template schema is corrupted, please check the template.yaml file.', ), ); - return ; + navigate(templateIndexLink()); + return <>{null}; } return ( diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 4a86c2e935..f731ebde40 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -23,4 +23,3 @@ export { } from './plugin'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; -export { rootRoute, templateRoute, taskRoute } from './routes'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index abb75355d0..c62117c996 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -21,10 +21,11 @@ import { identityApiRef, createRoutableExtension, } from '@backstage/core'; -import { ScaffolderPage as ScaffolderPageComponent } from './components/ScaffolderPage'; -import { TemplatePage as TemplatePageComponent } from './components/TemplatePage'; -import { TaskPage as TaskPageComponent } from './components/TaskPage'; -import { rootRoute, templateRoute, taskRoute } from './routes'; +import { + templateIndexRouteRef, + templateRouteRef, + taskRouteRef, +} from './routes'; import { scaffolderApiRef, ScaffolderClient } from './api'; export const scaffolderPlugin = createPlugin({ @@ -37,15 +38,10 @@ export const scaffolderPlugin = createPlugin({ new ScaffolderClient({ discoveryApi, identityApi }), }), ], - register({ router }) { - router.addRoute(rootRoute, ScaffolderPageComponent); - router.addRoute(templateRoute, TemplatePageComponent); - router.addRoute(taskRoute, TaskPageComponent); - }, routes: { - templateIndex: rootRoute, - template: templateRoute, - task: taskRoute, + templateIndex: templateIndexRouteRef, + template: templateRouteRef, + task: taskRouteRef, }, }); @@ -53,7 +49,7 @@ export const TemplateIndexPage = scaffolderPlugin.provide( createRoutableExtension({ component: () => import('./components/ScaffolderPage').then(m => m.ScaffolderPage), - mountPoint: rootRoute, + mountPoint: templateIndexRouteRef, }), ); @@ -61,13 +57,13 @@ export const TemplatePage = scaffolderPlugin.provide( createRoutableExtension({ component: () => import('./components/TemplatePage').then(m => m.TemplatePage), - mountPoint: templateRoute, + mountPoint: templateRouteRef, }), ); export const TaskPage = scaffolderPlugin.provide( createRoutableExtension({ component: () => import('./components/TaskPage').then(m => m.TaskPage), - mountPoint: taskRoute, + mountPoint: taskRouteRef, }), ); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 08f9f3bfa3..4884c1f83d 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -15,18 +15,16 @@ */ import { createRouteRef } from '@backstage/core'; -export const rootRoute = createRouteRef({ - path: '/create', +export const templateIndexRouteRef = createRouteRef({ title: 'Create new entity', }); -export const templateRoute = createRouteRef({ - path: '/create/:templateName', +export const templateRouteRef = createRouteRef({ title: 'Entity creation', + params: ['templateName'], }); -export const taskRoute = createRouteRef({ - path: '/scaffolder/tasks/:taskId', +export const taskRouteRef = createRouteRef({ title: 'Task information', params: ['taskId'], }); From fb9e95f75bf0b5156d8144d5fd2b358291dc036f Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 14:02:02 +0100 Subject: [PATCH 29/58] Export catalog and scaffolder directly Co-authored-by: Patrik Oldsberg --- packages/app/src/plugins.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index c62c0435a2..862d821ed6 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -14,8 +14,8 @@ * limitations under the License. */ export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; -export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; -export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; +export { catalogPlugin } from '@backstage/plugin-catalog'; +export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; export { explorePlugin } from '@backstage/plugin-explore'; export { plugin as Circleci } from '@backstage/plugin-circleci'; From 8a557a494ae6a26fa2ef71a9898872d03ec3d87e Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 14:02:46 +0100 Subject: [PATCH 30/58] Update scaffolder installation instructions Co-authored-by: Patrik Oldsberg --- .../software-templates/installation.md | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index a02755c9c7..4d128d4c20 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -33,7 +33,28 @@ it doesn't. Add the following entry to the head of your `packages/app/src/plugins.ts`: ```ts -export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; +export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; +``` + +Next we need to install the three pages that the scaffolder plugin provides. You +can choose any name for these routes, but we recommend the following: + +```tsx +import { TemplateIndexPage, TemplatePage, TaskPage } from '@backstage/plugin-scaffolder'; + +// Add to the top-level routes, directly within +} /> +} /> +} /> +``` + +You may also want to add a link to the template index page to your sidebar: + +```tsx +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; + +// Somewhere within the +; ``` This is all that is needed for the frontend part of the Scaffolder plugin to @@ -66,29 +87,26 @@ following contents to get you up and running quickly. import { CookieCutter, createRouter, - FilePreparer, - GithubPreparer, - GitlabPreparer, Preparers, Publishers, - GithubPublisher, - GitlabPublisher, CreateReactAppTemplater, Templaters, - RepoVisibilityOptions, + CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; -import { Octokit } from '@octokit/rest'; -import { Gitlab } from '@gitbeaker/node'; +import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; +import { CatalogClient } from '@backstage/catalog-client'; export default async function createPlugin({ logger, config, + database, }: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); @@ -96,12 +114,21 @@ export default async function createPlugin({ const publishers = await Publishers.fromConfig(config, { logger }); const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + return await createRouter({ preparers, templaters, publishers, logger, + config, dockerClient, + entityClient, + database, + catalogClient, }); } ``` From 536b6b5207c5fab3699283e702ce8556b3c61178 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 14:03:37 +0100 Subject: [PATCH 31/58] Update catalog install instructions Co-authored-by: Patrik Oldsberg --- .../features/software-catalog/installation.md | 64 +++++++++++++------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index b2afd62878..3b48760c2f 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -30,33 +30,59 @@ it doesn't. Add the following entry to the head of your `packages/app/src/plugins.ts`: ```ts -export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; +export { catalogPlugin } from '@backstage/plugin-catalog'; ``` -Add the following to your `packages/app/src/apis.ts`: +Next we need to install the two pages that the catalog plugin provides. You can +choose any name for these routes, but we recommend the following: + +```tsx +import { + catalogPlugin, + CatalogIndexPage, + CatalogEntityPage, +} from '@backstage/plugin-catalog'; + +// Add to the top-level routes, directly within +} /> +}> + {/* + This is the root of the custom entity pages for your app, refer to the example app + in the main repo or the output of @backstage/create-app for an example + */} + + +``` + +The catalog plugin also has one external route that needs to be bound for it to +functions, the `createComponent` route which should link to the page where the +user can create components. In a typical setup the create component route will +be linked to the Scaffolder plugins template index page: ```ts -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; +import { catalogPlugin } from '@backstage/plugin-catalog'; +import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -// Inside the ApiRegistry builder function ... - -builder.add( - catalogApiRef, - new CatalogClient({ - apiOrigin: backendUrl, - basePath: '/catalog', - }), -); +const app = createApp({ + // ... + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.templateIndex, + }); + }, +}); ``` -Where `backendUrl` is the `backend.baseUrl` from config, i.e. -`const backendUrl = config.getString('backend.baseUrl')`. +You may also want to add a link to the catalog index page to your sidebar: -The catalog components depend on a number of other -[Utility APIs](../../api/utility-apis.md) to function, including at least the -`ErrorApi` and `StorageApi`. You can find an example of how to install these in -your app -[here](https://github.com/backstage/backstage/blob/61c3a7e5b750dc7c059ef16b188594d31b2c04c2/packages/app/src/apis.ts#L80). +```tsx +import HomeIcon from '@material-ui/icons/Home'; + +// Somewhere within the +; +``` + +This is all that is needed for the frontend part of the Catalog plugin to work! ## Gotchas that we will fix From fe08d59921fa28fb4db18cc2add76efe938ff132 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 14:05:07 +0100 Subject: [PATCH 32/58] Replace deprecated catalog routing Co-authored-by: Patrik Oldsberg --- packages/app/src/App.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 9350106287..1cb83da41c 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -24,7 +24,8 @@ import { } from '@backstage/core'; import { catalogPlugin, - Router as CatalogRouter, + CatalogIndexPage, + CatalogEntityPage, } from '@backstage/plugin-catalog'; import { CatalogImportPage } from '@backstage/plugin-catalog-import'; import { ExplorePage } from '@backstage/plugin-explore'; @@ -88,11 +89,14 @@ const catalogRouteRef = createRouteRef({ const routes = ( - } /> + } /> } - /> + path="/catalog/:namespace/:kind/:name" + element={} + > + + + } /> } /> } /> } /> From de093269598aeee47c21d8106601232f23278c4e Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 16:15:38 +0100 Subject: [PATCH 33/58] Make sure createdAt is properly formated Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- plugins/scaffolder-backend/package.json | 1 + .../src/scaffolder/tasks/DatabaseTaskStore.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 02f2f9e976..4a6b455539 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -56,6 +56,7 @@ "isomorphic-git": "^1.8.0", "jsonschema": "^1.2.6", "knex": "^0.21.6", + "luxon": "^1.26.0", "morgan": "^1.10.0", "uuid": "^8.2.0", "winston": "^3.2.1", diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts index 1dee11ba20..d069008282 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -32,6 +32,7 @@ import { TaskStoreEmitOptions, TaskStoreGetEventsOptions, } from './types'; +import { DateTime } from 'luxon'; const migrationsDir = resolvePackagePath( '@backstage/plugin-scaffolder-backend', @@ -259,7 +260,10 @@ export class DatabaseTaskStore implements TaskStore { taskId, body, type: event.event_type, - createdAt: event.created_at, + createdAt: + typeof event.created_at === 'string' + ? DateTime.fromSQL(event.created_at, { zone: 'UTC' }).toISO() + : event.created_at, }; } catch (error) { throw new Error( From 285deed5de42dd399cdbd74c1d0ccedf1f0cf7a5 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Feb 2021 16:20:24 +0100 Subject: [PATCH 34/58] undo derp comment --- packages/core-api/src/app/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 17a6312e66..1970868ca6 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -159,7 +159,7 @@ export type AppOptions = { * } * ``` */ - bindRoutes?(context: { /** le derp */ bind: AppRouteBinder }): void; + bindRoutes?(context: { bind: AppRouteBinder }): void; }; export type BackstageApp = { From 0b7cb922587d33667b76edf48ea2e36a73c07ace Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 11:06:20 +0100 Subject: [PATCH 35/58] Fix routes Co-authored-by: Patrik Oldsberg --- .../features/software-catalog/installation.md | 2 +- .../software-templates/installation.md | 10 +++--- packages/app/src/App.tsx | 13 ++------ plugins/scaffolder/src/components/Router.tsx | 29 +++++++++++++++++ .../components/TemplateCard/TemplateCard.tsx | 10 ++++-- .../components/TemplatePage/TemplatePage.tsx | 14 ++++---- plugins/scaffolder/src/index.ts | 4 +-- plugins/scaffolder/src/plugin.ts | 32 +++---------------- plugins/scaffolder/src/routes.ts | 12 +------ 9 files changed, 58 insertions(+), 68 deletions(-) create mode 100644 plugins/scaffolder/src/components/Router.tsx diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index 3b48760c2f..309951d58c 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -67,7 +67,7 @@ const app = createApp({ // ... bindRoutes({ bind }) { bind(catalogPlugin.externalRoutes, { - createComponent: scaffolderPlugin.routes.templateIndex, + createComponent: scaffolderPlugin.routes.root, }); }, }); diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 4d128d4c20..d5bd805c10 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -36,16 +36,14 @@ Add the following entry to the head of your `packages/app/src/plugins.ts`: export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; ``` -Next we need to install the three pages that the scaffolder plugin provides. You -can choose any name for these routes, but we recommend the following: +Next we need to install the root page that the Scaffolder plugin provides. You +can choose any path for the route, but we recommend the following: ```tsx -import { TemplateIndexPage, TemplatePage, TaskPage } from '@backstage/plugin-scaffolder'; +import { ScaffolderPage } from '@backstage/plugin-scaffolder'; // Add to the top-level routes, directly within -} /> -} /> -} /> +} />; ``` You may also want to add a link to the template index page to your sidebar: diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 1cb83da41c..4a49db02d5 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -32,12 +32,7 @@ import { ExplorePage } from '@backstage/plugin-explore'; import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; -import { - TemplateIndexPage, - TemplatePage, - TaskPage, - scaffolderPlugin, -} from '@backstage/plugin-scaffolder'; +import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; @@ -72,7 +67,7 @@ const app = createApp({ }, bindRoutes({ bind }) { bind(catalogPlugin.externalRoutes, { - createComponent: scaffolderPlugin.routes.templateIndex, + createComponent: scaffolderPlugin.routes.root, }); }, }); @@ -98,9 +93,7 @@ const routes = ( } /> } /> - } /> - } /> - } /> + } /> } /> ( + + } /> + } /> + } /> + +); diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 7d65bd1307..c38f4e09a5 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -25,7 +25,8 @@ import { useTheme, } from '@material-ui/core'; import React from 'react'; -import { templateRouteRef } from '../../routes'; +import { generatePath } from 'react-router'; +import { rootRouteRef } from '../../routes'; const useStyles = makeStyles(theme => ({ header: { @@ -58,11 +59,14 @@ export const TemplateCard = ({ name, }: TemplateCardProps) => { const backstageTheme = useTheme(); + const rootLink = useRouteRef(rootRouteRef); const themeId = pageTheme[type] ? type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); - const templateLink = useRouteRef(templateRouteRef); + const href = generatePath(`${rootLink()}/templates/:templateName`, { + templateName: name, + }); return ( @@ -79,7 +83,7 @@ export const TemplateCard = ({ - diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index c29f73a629..030cd5dccb 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -29,11 +29,11 @@ import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useState } from 'react'; -import { useNavigate } from 'react-router'; +import { generatePath, useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; -import { taskRouteRef, templateIndexRouteRef } from '../../routes'; +import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; const useTemplate = ( @@ -78,8 +78,7 @@ export const TemplatePage = () => { const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); const navigate = useNavigate(); - const tasksLink = useRouteRef(taskRouteRef); - const templateIndexLink = useRouteRef(templateIndexRouteRef); + const rootLink = useRouteRef(rootRouteRef); const { template, loading } = useTemplate(templateName, catalogApi); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); @@ -92,7 +91,8 @@ export const TemplatePage = () => { const handleCreate = async () => { try { const id = await scaffolderApi.scaffold(templateName, formState); - navigate(tasksLink({ taskId: id })); + + navigate(generatePath(`${rootLink()}/tasks/:taskId`, { taskId: id })); } catch (e) { errorApi.post(e); } @@ -100,7 +100,7 @@ export const TemplatePage = () => { if (!loading && !template) { errorApi.post(new Error('Template was not found.')); - navigate(templateIndexLink()); + navigate(rootLink()); return <>{null}; } @@ -110,7 +110,7 @@ export const TemplatePage = () => { 'Template schema is corrupted, please check the template.yaml file.', ), ); - navigate(templateIndexLink()); + navigate(rootLink()); return <>{null}; } diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index f731ebde40..e0b574e6c6 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -17,9 +17,7 @@ export { scaffolderPlugin, scaffolderPlugin as plugin, - TemplateIndexPage, - TemplatePage, - TaskPage, + ScaffolderPage, } from './plugin'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index c62117c996..a6ba1a9899 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -21,11 +21,7 @@ import { identityApiRef, createRoutableExtension, } from '@backstage/core'; -import { - templateIndexRouteRef, - templateRouteRef, - taskRouteRef, -} from './routes'; +import { rootRouteRef } from './routes'; import { scaffolderApiRef, ScaffolderClient } from './api'; export const scaffolderPlugin = createPlugin({ @@ -39,31 +35,13 @@ export const scaffolderPlugin = createPlugin({ }), ], routes: { - templateIndex: templateIndexRouteRef, - template: templateRouteRef, - task: taskRouteRef, + root: rootRouteRef, }, }); -export const TemplateIndexPage = scaffolderPlugin.provide( +export const ScaffolderPage = scaffolderPlugin.provide( createRoutableExtension({ - component: () => - import('./components/ScaffolderPage').then(m => m.ScaffolderPage), - mountPoint: templateIndexRouteRef, - }), -); - -export const TemplatePage = scaffolderPlugin.provide( - createRoutableExtension({ - component: () => - import('./components/TemplatePage').then(m => m.TemplatePage), - mountPoint: templateRouteRef, - }), -); - -export const TaskPage = scaffolderPlugin.provide( - createRoutableExtension({ - component: () => import('./components/TaskPage').then(m => m.TaskPage), - mountPoint: taskRouteRef, + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, }), ); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 4884c1f83d..413e8f4194 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -15,16 +15,6 @@ */ import { createRouteRef } from '@backstage/core'; -export const templateIndexRouteRef = createRouteRef({ +export const rootRouteRef = createRouteRef({ title: 'Create new entity', }); - -export const templateRouteRef = createRouteRef({ - title: 'Entity creation', - params: ['templateName'], -}); - -export const taskRouteRef = createRouteRef({ - title: 'Task information', - params: ['taskId'], -}); From 17d1d909e4f4f5f14161b580f0b1fde4f3d8e023 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 11:23:06 +0100 Subject: [PATCH 36/58] Replace EntityClient with CatalogClient Co-authored-by: Patrik Oldsberg --- docs/features/software-templates/installation.md | 3 --- packages/backend/package.json | 1 + packages/backend/src/plugins/scaffolder.ts | 3 --- .../src/lib/catalog/CatalogEntityClient.ts | 14 ++------------ plugins/scaffolder-backend/src/service/router.ts | 3 +-- yarn.lock | 7 +++++-- 6 files changed, 9 insertions(+), 22 deletions(-) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index d5bd805c10..9aa1d3bc8f 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -89,7 +89,6 @@ import { Publishers, CreateReactAppTemplater, Templaters, - CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; @@ -114,7 +113,6 @@ export default async function createPlugin({ const dockerClient = new Docker(); const discovery = SingleHostDiscovery.fromConfig(config); - const entityClient = new CatalogEntityClient({ discovery }); const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ @@ -124,7 +122,6 @@ export default async function createPlugin({ logger, config, dockerClient, - entityClient, database, catalogClient, }); diff --git a/packages/backend/package.json b/packages/backend/package.json index e5271f0109..e585d20035 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@backstage/backend-common": "^0.5.3", + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", "@backstage/plugin-app-backend": "^0.3.7", diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index ae4b22759b..10da5f0e15 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -21,7 +21,6 @@ import { Publishers, CreateReactAppTemplater, Templaters, - CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; @@ -46,7 +45,6 @@ export default async function createPlugin({ const dockerClient = new Docker(); const discovery = SingleHostDiscovery.fromConfig(config); - const entityClient = new CatalogEntityClient({ discovery }); const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ @@ -56,7 +54,6 @@ export default async function createPlugin({ logger, config, dockerClient, - entityClient, database, catalogClient, }); diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index 827ed0559a..13cfd32f6a 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -16,23 +16,13 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { CatalogClient } from '@backstage/catalog-client'; -import { - ConflictError, - NotFoundError, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; +import { ConflictError, NotFoundError } from '@backstage/backend-common'; /** * A catalog client tailored for reading out entity data from the catalog. */ export class CatalogEntityClient { - private readonly catalogClient: CatalogClient; - - constructor(options: { discovery: PluginEndpointDiscovery }) { - this.catalogClient = new CatalogClient({ - discoveryApi: options.discovery, - }); - } + constructor(private readonly catalogClient: CatalogClient) {} /** * Looks up a single template using a template name. diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index f5b52006e6..bb5fec306a 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -58,7 +58,6 @@ export interface RouterOptions { logger: Logger; config: Config; dockerClient: Docker; - entityClient: CatalogEntityClient; database: PluginDatabaseManager; catalogClient: CatalogClient; } @@ -76,7 +75,6 @@ export async function createRouter( logger: parentLogger, config, dockerClient, - entityClient, database, catalogClient, } = options; @@ -84,6 +82,7 @@ export async function createRouter( const logger = parentLogger.child({ plugin: 'scaffolder' }); const workingDirectory = await getWorkingDirectory(config, logger); const jobProcessor = await JobProcessor.fromConfig({ config, logger }); + const entityClient = new CatalogEntityClient(catalogClient); const databaseTaskStore = await DatabaseTaskStore.create( await database.getClient(), diff --git a/yarn.lock b/yarn.lock index da8cb59368..f4168c3778 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1872,7 +1872,6 @@ "@backstage/catalog-model" "^0.7.1" "@backstage/core" "^0.6.1" "@backstage/plugin-catalog-react" "^0.0.3" - "@backstage/plugin-scaffolder" "^0.5.0" "@backstage/theme" "^0.2.3" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -1895,7 +1894,6 @@ "@backstage/catalog-model" "^0.7.1" "@backstage/core" "^0.6.1" "@backstage/plugin-catalog-react" "^0.0.3" - "@backstage/plugin-scaffolder" "^0.5.0" "@backstage/theme" "^0.2.3" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -17525,6 +17523,11 @@ luxon@1.25.0, luxon@^1.25.0: resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ== +luxon@^1.26.0: + version "1.26.0" + resolved "https://registry.npmjs.org/luxon/-/luxon-1.26.0.tgz#d3692361fda51473948252061d0f8561df02b578" + integrity sha512-+V5QIQ5f6CDXQpWNICELwjwuHdqeJM1UenlZWx5ujcRMc9venvluCjFb4t5NYLhb6IhkbMVOxzVuOqkgMxee2A== + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" From a5f42cf66f5e7f580d17bfe07b069ae44d929418 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 11:30:35 +0100 Subject: [PATCH 37/58] Add changesets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- .changeset/clever-tomatoes-change.md | 53 ++++++++++++++++++++++++++++ .changeset/dingo-dongo.md | 41 +++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 .changeset/clever-tomatoes-change.md create mode 100644 .changeset/dingo-dongo.md diff --git a/.changeset/clever-tomatoes-change.md b/.changeset/clever-tomatoes-change.md new file mode 100644 index 0000000000..f1041f28c6 --- /dev/null +++ b/.changeset/clever-tomatoes-change.md @@ -0,0 +1,53 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': minor +--- + +# Stateless scaffolding + +The scaffolder has been redesigned to be horizontally scalable and to persistently store task state and execution logs in the database. + +Each scaffolder task is given a unique task ID which is persisted in the database. +Tasks are then picked up by a `TaskWorker` which performs the scaffolding steps. +Execution logs are also peristed in the database meaning you can now refresh the scaffolder task status page without losing information. + +The task status page is now dynamically created based on the step information stored in the database. +This allows for custom steps to be displayed once the next version of the scaffolder template schema is available. + +The task page is updated to display links to both the git repository and to the newly created catalog entity. + +Component registration has moved from the frontend into a separate registration step executed by the `TaskWorker`. This requires that a `CatalogClient` is passed to the scaffoler backend instead of the old `CatalogEntityClient`. + +Make sure to update `plugins/scaffolder.ts` + +```diff + import { + CookieCutter, + createRouter, + Preparers, + Publishers, + CreateReactAppTemplater, + Templaters, +- CatalogEntityClient, + } from '@backstage/plugin-scaffolder-backend'; + ++import { CatalogClient } from '@backstage/catalog-client'; + + const discovery = SingleHostDiscovery.fromConfig(config); +-const entityClient = new CatalogEntityClient({ discovery }); ++const catalogClient = new CatalogClient({ discoveryApi: discovery }) + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, +- entityClient, + database, ++ catalogClient, + }); +``` + +As well as adding the `@backstage/catalog-client` packages as a dependency of your backend package. diff --git a/.changeset/dingo-dongo.md b/.changeset/dingo-dongo.md new file mode 100644 index 0000000000..87c4172448 --- /dev/null +++ b/.changeset/dingo-dongo.md @@ -0,0 +1,41 @@ +--- +'@backstage/plugin-catalog': minor +'@backstage/plugin-scaffolder': minor +--- + +The Scaffolder and Catalog plugins have been migrated to partially require use of the [new composability API](https://backstage.io/docs/plugins/composability). The Scaffolder used to register its pages using the deprecated route registration plugin API, but those registrations have been removed. This means you now need to add the Scaffolder plugin page to the app directly. + +The page is imported from the Scaffolder plugin and added to the `` component: + +```tsx +} /> +``` + +You can choose your own paths if you wish, as long as the `templateName` and `taskId` parameters are present. + +The Catalog plugin has also been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. This means you need to migrate the catalog plugin to use the new extension components, as well as bind the external route. + +To use the new extension components, replace existing usage of the `CatalogRouter` with the following: + +```tsx +} /> +}> + + +``` + +And to bind the external route from the catalog plugin to the scaffolder template index page, make sure you have the appropriate imports and add the following to the `createApp` call: + +```ts +import { catalogPlugin } from '@backstage/plugin-catalog'; +import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; + +const app = createApp({ + // ... + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + }, +}); +``` From f6c81bb8d54f4039ba3b3f9bb9beddfb0770dc19 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 11:33:29 +0100 Subject: [PATCH 38/58] create-app: Update scaffolder routes --- .../default-app/packages/app/src/App.tsx | 22 +++++++++++++++---- .../backend/src/plugins/scaffolder.ts | 6 ++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 711e501224..cf31647ce0 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -10,7 +10,11 @@ import { apis } from './apis'; import * as plugins from './plugins'; import { AppSidebar } from './sidebar'; import { Route, Navigate } from 'react-router'; -import { Router as CatalogRouter } from '@backstage/plugin-catalog'; +import { + catalogPlugin, + CatalogIndexPage, + CatalogEntityPage, +} from '@backstage/plugin-catalog'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; import { CatalogImportPage } from '@backstage/plugin-catalog-import'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; @@ -18,10 +22,16 @@ import { SearchPage as SearchRouter } from '@backstage/plugin-search'; import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; import { EntityPage } from './components/catalog/EntityPage'; +import { scaffolderPlugin, ScaffolderPage } from '@backstage/plugin-scaffolder'; const app = createApp({ apis, plugins: Object.values(plugins), + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + } }); const AppProvider = app.getProvider(); @@ -37,11 +47,15 @@ const App = () => ( + } /> } - /> + path="/catalog/:namespace/:kind/:name" + element={} + > + + } /> + } /> } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index d68f90ce08..84657948e5 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -5,11 +5,11 @@ import { Publishers, CreateReactAppTemplater, Templaters, - CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; +import { CatalogClient } from '@backstage/catalog-client'; export default async function createPlugin({ logger, @@ -29,7 +29,7 @@ export default async function createPlugin({ const dockerClient = new Docker(); const discovery = SingleHostDiscovery.fromConfig(config); - const entityClient = new CatalogEntityClient({ discovery }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }) return await createRouter({ preparers, @@ -38,7 +38,7 @@ export default async function createPlugin({ logger, config, dockerClient, - entityClient, database, + catalogClient, }); } From 831b65502ba35570d0c7170dc7d5092bf25b5e40 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 13:09:26 +0100 Subject: [PATCH 39/58] Fix typos --- .changeset/clever-tomatoes-change.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/clever-tomatoes-change.md b/.changeset/clever-tomatoes-change.md index f1041f28c6..c57953efb9 100644 --- a/.changeset/clever-tomatoes-change.md +++ b/.changeset/clever-tomatoes-change.md @@ -9,14 +9,14 @@ The scaffolder has been redesigned to be horizontally scalable and to persistent Each scaffolder task is given a unique task ID which is persisted in the database. Tasks are then picked up by a `TaskWorker` which performs the scaffolding steps. -Execution logs are also peristed in the database meaning you can now refresh the scaffolder task status page without losing information. +Execution logs are also persisted in the database meaning you can now refresh the scaffolder task status page without losing information. The task status page is now dynamically created based on the step information stored in the database. This allows for custom steps to be displayed once the next version of the scaffolder template schema is available. The task page is updated to display links to both the git repository and to the newly created catalog entity. -Component registration has moved from the frontend into a separate registration step executed by the `TaskWorker`. This requires that a `CatalogClient` is passed to the scaffoler backend instead of the old `CatalogEntityClient`. +Component registration has moved from the frontend into a separate registration step executed by the `TaskWorker`. This requires that a `CatalogClient` is passed to the scaffolder backend instead of the old `CatalogEntityClient`. Make sure to update `plugins/scaffolder.ts` From 86225bae7ed3f12677366c6c5d61e55ea00b0551 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 13:27:39 +0100 Subject: [PATCH 40/58] Fix scaffolder dev routes Co-authored-by: Patrik Oldsberg --- plugins/scaffolder/dev/index.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 2bc7c76a3a..1501a1cd5e 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -19,7 +19,7 @@ import { createDevApp } from '@backstage/dev-utils'; import { discoveryApiRef, identityApiRef } from '@backstage/core'; import { CatalogClient } from '@backstage/catalog-client'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { TemplateIndexPage, TemplatePage, TaskPage } from '../src/plugin'; +import { ScaffolderPage } from '../src/plugin'; import { ScaffolderClient, scaffolderApiRef } from '../src'; createDevApp() @@ -37,14 +37,6 @@ createDevApp() .addPage({ path: '/create', title: 'Create', - element: , - }) - .addPage({ - path: '/create/:templateName', - element: , - }) - .addPage({ - path: '/scaffolder/tasks/:taskId', - element: , + element: , }) .render(); From 478d87e6129cef8f00c4af838fc4a25b11eeffc6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 13:28:31 +0100 Subject: [PATCH 41/58] Pass CatalogApi instead of CatalogClient Co-authored-by: Patrik Oldsberg --- .../src/lib/catalog/CatalogEntityClient.ts | 4 +-- .../src/service/router.test.ts | 25 +++++++------------ .../scaffolder-backend/src/service/router.ts | 4 +-- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index 13cfd32f6a..cea0e85148 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -15,14 +15,14 @@ */ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { CatalogClient } from '@backstage/catalog-client'; +import { CatalogApi } from '@backstage/catalog-client'; import { ConflictError, NotFoundError } from '@backstage/backend-common'; /** * A catalog client tailored for reading out entity data from the catalog. */ export class CatalogEntityClient { - constructor(private readonly catalogClient: CatalogClient) {} + constructor(private readonly catalogClient: CatalogApi) {} /** * Looks up a single template using a template name. diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index b5de493680..d8a236a213 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -39,16 +39,14 @@ import request from 'supertest'; import { createRouter } from './router'; import { Templaters, Preparers, Publishers } from '../scaffolder'; import Docker from 'dockerode'; -import { CatalogClient } from '@backstage/catalog-client'; - -jest.mock('@backstage/catalog-client'); -const MockedCatalogClient = CatalogClient as jest.Mock; +import { CatalogApi } from '@backstage/catalog-client'; jest.mock('dockerode'); -const generateEntityClient: any = (template: any) => ({ - findTemplate: () => Promise.resolve(template), -}); +const createCatalogClient = (templates: any[] = []) => + ({ + getEntities: async () => ({ items: templates }), + } as CatalogApi); function createDatabase(): PluginDatabaseManager { return SingleConnectionDatabaseManager.fromConfig( @@ -99,7 +97,6 @@ describe('createRouter - working directory', () => { }, }; - const mockedEntityClient = generateEntityClient(template); it('should throw an error when working directory does not exist or is not writable', async () => { mockAccess.mockImplementation(() => { throw new Error('access error'); @@ -113,9 +110,8 @@ describe('createRouter - working directory', () => { publishers: new Publishers(), config: new ConfigReader(workDirConfig('/path')), dockerClient: new Docker(), - entityClient: mockedEntityClient, database: createDatabase(), - catalogClient: new MockedCatalogClient(), + catalogClient: createCatalogClient([template]), }), ).rejects.toThrow('access error'); }); @@ -128,9 +124,8 @@ describe('createRouter - working directory', () => { publishers: new Publishers(), config: new ConfigReader(workDirConfig('/path')), dockerClient: new Docker(), - entityClient: mockedEntityClient, database: createDatabase(), - catalogClient: new MockedCatalogClient(), + catalogClient: createCatalogClient([template]), }); const app = express().use(router); @@ -158,9 +153,8 @@ describe('createRouter - working directory', () => { publishers: new Publishers(), config: new ConfigReader({}), dockerClient: new Docker(), - entityClient: mockedEntityClient, database: createDatabase(), - catalogClient: new MockedCatalogClient(), + catalogClient: createCatalogClient([template]), }); const app = express().use(router); @@ -229,9 +223,8 @@ describe('createRouter', () => { publishers: new Publishers(), config: new ConfigReader({}), dockerClient: new Docker(), - entityClient: generateEntityClient(template), database: createDatabase(), - catalogClient: new MockedCatalogClient(), + catalogClient: createCatalogClient([template]), }); app = express().use(router); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index bb5fec306a..5adfee275e 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -48,7 +48,7 @@ import { NotFoundError, PluginDatabaseManager, } from '@backstage/backend-common'; -import { CatalogClient } from '@backstage/catalog-client'; +import { CatalogApi } from '@backstage/catalog-client'; export interface RouterOptions { preparers: PreparerBuilder; @@ -59,7 +59,7 @@ export interface RouterOptions { config: Config; dockerClient: Docker; database: PluginDatabaseManager; - catalogClient: CatalogClient; + catalogClient: CatalogApi; } export async function createRouter( From 08fa2176a30e62c325609c443ff56a3cf6f90958 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 14:20:30 +0100 Subject: [PATCH 42/58] Update changesets Co-authored-by: Patrik Oldsberg --- .changeset/dingo-dongo.md | 2 - .changeset/weak-foxes-explain.md | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .changeset/weak-foxes-explain.md diff --git a/.changeset/dingo-dongo.md b/.changeset/dingo-dongo.md index 87c4172448..424c88f61c 100644 --- a/.changeset/dingo-dongo.md +++ b/.changeset/dingo-dongo.md @@ -11,8 +11,6 @@ The page is imported from the Scaffolder plugin and added to the `` } /> ``` -You can choose your own paths if you wish, as long as the `templateName` and `taskId` parameters are present. - The Catalog plugin has also been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. This means you need to migrate the catalog plugin to use the new extension components, as well as bind the external route. To use the new extension components, replace existing usage of the `CatalogRouter` with the following: diff --git a/.changeset/weak-foxes-explain.md b/.changeset/weak-foxes-explain.md new file mode 100644 index 0000000000..5536e1982c --- /dev/null +++ b/.changeset/weak-foxes-explain.md @@ -0,0 +1,86 @@ +--- +'@backstage/create-app': patch +--- + +**BREAKING CHANGE** + +The Scaffolder and Catalog plugins have been migrated to partially require use of the [new composability API](https://backstage.io/docs/plugins/composability). The Scaffolder used to register its pages using the deprecated route registration plugin API, but those registrations have been removed. This means you now need to add the Scaffolder plugin page to the app directly. + +The Catalog plugin has also been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. This means you need to migrate the catalog plugin to use the new extension components, as well as bind the external route. + +Apply the following changes to `packages/app/src/App.tsx`: + +```diff +-import { Router as CatalogRouter } from '@backstage/plugin-catalog'; ++import { ++ catalogPlugin, ++ CatalogIndexPage, ++ CatalogEntityPage, ++} from '@backstage/plugin-catalog'; ++import { scaffolderPlugin, ScaffolderPage } from '@backstage/plugin-scaffolder'; + +# The following addition to the app config allows the catalog plugin to link to the +# component creation page, i.e. the scaffolder. You can chose a different target if you want to. + const app = createApp({ + apis, + plugins: Object.values(plugins), ++ bindRoutes({ bind }) { ++ bind(catalogPlugin.externalRoutes, { ++ createComponent: scaffolderPlugin.routes.root, ++ }); ++ } + }); + +# Apply these changes within FlatRoutes. It is important to have migrated to using FlatRoutes +# for this to work, if you haven't done that yet, see the previous entries in this changelog. +- } +- /> ++ } /> ++ } ++ > ++ ++ + } /> ++ } /> +``` + +The scaffolder has been redesigned to be horizontally scalable and to persistently store task state and execution logs in the database. Component registration has moved from the frontend into a separate registration step executed by the `TaskWorker`. This requires that a `CatalogClient` is passed to the scaffolder backend instead of the old `CatalogEntityClient`. + +The default catalog client comes from the `@backstage/catalog-client`, which you need to add as a dependency in `packages/backend/package.json`. + +Once the dependency has been added, apply the following changes to`packages/backend/src/plugins/scaffolder.ts`: + +```diff + import { + CookieCutter, + createRouter, + Preparers, + Publishers, + CreateReactAppTemplater, + Templaters, +- CatalogEntityClient, + } from '@backstage/plugin-scaffolder-backend'; ++import { CatalogClient } from '@backstage/catalog-client'; + + const discovery = SingleHostDiscovery.fromConfig(config); +-const entityClient = new CatalogEntityClient({ discovery }); ++const catalogClient = new CatalogClient({ discoveryApi: discovery }) + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, +- entityClient, + database, ++ catalogClient, + }); +``` + +See the `@backstage/scaffolder-backend` changelog for more information about this change. From d934a66e3ecfb589d1abc0e012527b1ce7750a53 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 14:21:34 +0100 Subject: [PATCH 43/58] core-api: Export ExternalRouteRef Co-authored-by: Patrik Oldsberg --- packages/core-api/src/routing/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index ad88f8ff02..100158c3c5 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -21,6 +21,10 @@ export type { MutableRouteRef, } from './types'; export { FlatRoutes } from './FlatRoutes'; -export { createRouteRef, createExternalRouteRef } from './RouteRef'; +export { + createRouteRef, + createExternalRouteRef, + ExternalRouteRef, +} from './RouteRef'; export type { RouteRefConfig } from './RouteRef'; export { useRouteRef } from './hooks'; From 0b94e83ba4297138f4824bbbb92b75cf66591984 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 14:22:08 +0100 Subject: [PATCH 44/58] create-app: Add catalog-client dependency Co-authored-by: Patrik Oldsberg --- packages/create-app/package.json | 1 + packages/create-app/src/lib/versions.ts | 2 ++ .../templates/default-app/packages/backend/package.json.hbs | 1 + 3 files changed, 4 insertions(+) diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 5e4cf8ae11..acf61ab76a 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -53,6 +53,7 @@ "@backstage/plugin-app-backend": "^0.3.7", "@backstage/plugin-auth-backend": "^0.3.0", "@backstage/plugin-catalog": "^0.3.1", + "@backstage/catalog-client": "^0.3.6", "@backstage/plugin-catalog-backend": "^0.6.1", "@backstage/plugin-catalog-import": "^0.4.0", "@backstage/plugin-circleci": "^0.2.8", diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index e6a047921c..9a05a95ac0 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -31,6 +31,7 @@ leaving any imports in place. */ import { version as backendCommon } from '../../../backend-common/package.json'; +import { version as catalogClient } from '../../../catalog-client/package.json'; import { version as catalogModel } from '../../../catalog-model/package.json'; import { version as cli } from '../../../cli/package.json'; import { version as config } from '../../../config/package.json'; @@ -61,6 +62,7 @@ import { version as pluginUserSettings } from '../../../../plugins/user-settings export const packageVersions = { '@backstage/backend-common': backendCommon, + '@backstage/catalog-client': catalogClient, '@backstage/catalog-model': catalogModel, '@backstage/cli': cli, '@backstage/config': config, diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 3fed72f07b..daee44d8c7 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -20,6 +20,7 @@ "app": "0.0.0", "@backstage/backend-common": "^{{version '@backstage/backend-common'}}", "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", + "@backstage/catalog-client": "^{{version '@backstage/catalog-client'}}", "@backstage/config": "^{{version '@backstage/config'}}", "@backstage/plugin-app-backend": "^{{version '@backstage/plugin-app-backend'}}", "@backstage/plugin-auth-backend": "^{{version '@backstage/plugin-auth-backend'}}", From dc12852c9e78e9b9884f2de5420eed50596a25d0 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 14:54:35 +0100 Subject: [PATCH 45/58] test-utils: Allow ExternalRouteRef on mountedRoutes Co-authored-by: Patrik Oldsberg --- .changeset/cyan-dingos-watch.md | 5 +++++ packages/test-utils/src/testUtils/appWrappers.tsx | 3 ++- .../catalog/src/components/CatalogPage/CatalogPage.test.tsx | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changeset/cyan-dingos-watch.md diff --git a/.changeset/cyan-dingos-watch.md b/.changeset/cyan-dingos-watch.md new file mode 100644 index 0000000000..7b15d30112 --- /dev/null +++ b/.changeset/cyan-dingos-watch.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Allow `ExternalRouteRef` instances to be passed as a route ref to `mountedRoutes`. diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index cc67a5b707..56d6e07805 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -22,6 +22,7 @@ import privateExports, { defaultSystemIcons, BootErrorPageProps, RouteRef, + ExternalRouteRef, createPlugin, createRoutableExtension, } from '@backstage/core-api'; @@ -62,7 +63,7 @@ type TestAppOptions = { * // ... * const link = useRouteRef(myRouteRef) */ - mountedRoutes?: { [path: string]: RouteRef }; + mountedRoutes?: { [path: string]: RouteRef | ExternalRouteRef }; }; /** diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 6d4d18e4f2..accfe6eda4 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -33,6 +33,7 @@ import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; +import { createComponentRouteRef } from '../../routes'; import { CatalogPage } from './CatalogPage'; describe('CatalogPage', () => { @@ -116,6 +117,11 @@ describe('CatalogPage', () => { > {children}, , + { + mountedRoutes: { + '/create': createComponentRouteRef, + }, + }, ), ); From 34b710f0c0d796a365cf83187bd9aa22fcd09066 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 16:08:08 +0100 Subject: [PATCH 46/58] test-utils: Attach mountPoint to Page Co-authored-by: Patrik Oldsberg --- packages/test-utils/src/testUtils/appWrappers.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 56d6e07805..05cd0b8fd4 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -23,8 +23,7 @@ import privateExports, { BootErrorPageProps, RouteRef, ExternalRouteRef, - createPlugin, - createRoutableExtension, + attachComponentData, } from '@backstage/core-api'; import { RenderResult } from '@testing-library/react'; import { renderWithEffects } from '@backstage/test-utils-core'; @@ -110,16 +109,10 @@ export function wrapInTestApp( Wrapper = () => Component as React.ReactElement; } - const routePlugin = createPlugin({ id: 'mock-route-plugin' }); const routeElements = Object.entries(options.mountedRoutes ?? {}).map( ([path, routeRef]) => { - const PageComponent = () =>
Mounted at {path}
; - const Page = routePlugin.provide( - createRoutableExtension({ - component: async () => PageComponent, - mountPoint: routeRef, - }), - ); + const Page = () =>
Mounted at {path}
; + attachComponentData(Page, 'core.mountPoint', routeRef); return } />; }, ); From 8e348f05dbade1a07c94576887483db60c1a7727 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 16:11:08 +0100 Subject: [PATCH 47/58] Update docs/features/software-catalog/installation.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-catalog/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index 309951d58c..039c75391a 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -55,7 +55,7 @@ import { ``` The catalog plugin also has one external route that needs to be bound for it to -functions, the `createComponent` route which should link to the page where the +function: the `createComponent` route which should link to the page where the user can create components. In a typical setup the create component route will be linked to the Scaffolder plugins template index page: From 6ea4db0b2c115fe57b8276c6e17e34e9fd36fa7f Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Feb 2021 16:11:16 +0100 Subject: [PATCH 48/58] Update docs/features/software-catalog/installation.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-catalog/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index 039c75391a..ae8c671ed1 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -57,7 +57,7 @@ import { The catalog plugin also has one external route that needs to be bound for it to function: the `createComponent` route which should link to the page where the user can create components. In a typical setup the create component route will -be linked to the Scaffolder plugins template index page: +be linked to the Scaffolder plugin's template index page: ```ts import { catalogPlugin } from '@backstage/plugin-catalog'; From d9685d0b1e62b3bc0df0a686edf0f7025abbfb45 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Feb 2021 08:59:57 +0100 Subject: [PATCH 49/58] Update plugins/scaffolder/src/api.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- plugins/scaffolder/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 4978f7fafa..20fca5b3f2 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -45,7 +45,7 @@ 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 templateName Name of the Template entity 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): Promise; From b6cf3cfc3c72200583d6519d6f8cd3eeec6b1855 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Feb 2021 09:06:03 +0100 Subject: [PATCH 50/58] Update plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts Co-authored-by: Adam Harvey --- plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 6eac36b3f3..e717fcae1b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -53,7 +53,7 @@ export class TaskWorker { ); await fs.ensureDir(workspacePath); await task.emitLog( - `Starting up work with ${task.spec.steps.length} steps`, + `Starting up task with ${task.spec.steps.length} steps`, ); const templateCtx: { From 7c5b0e206faf6f32ed84fed9dbd3a75c621866d1 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Feb 2021 09:08:12 +0100 Subject: [PATCH 51/58] Update plugins/scaffolder-backend/src/service/router.ts Co-authored-by: Adam Harvey --- plugins/scaffolder-backend/src/service/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 5adfee275e..3d10bbd101 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -259,7 +259,7 @@ export async function createRouter( const { taskId } = req.params; const task = await taskBroker.get(taskId); if (!task) { - throw new NotFoundError(`task with id ${taskId} does not exist`); + throw new NotFoundError(`Task with id ${taskId} does not exist`); } res.status(200).json(task); }) From 75e40350fa94672baad0aa37a053dacce25a6583 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Feb 2021 09:16:24 +0100 Subject: [PATCH 52/58] scaffolder: Use default ErrorPage --- .../components/TaskNotFound/TaskNotFound.tsx | 59 ------------------- .../src/components/TaskPage/TaskPage.tsx | 9 ++- 2 files changed, 6 insertions(+), 62 deletions(-) delete mode 100644 plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx diff --git a/plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx b/plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx deleted file mode 100644 index 24ac8c357d..0000000000 --- a/plugins/scaffolder/src/components/TaskNotFound/TaskNotFound.tsx +++ /dev/null @@ -1,59 +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 React from 'react'; -import { Grid, Typography } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import { BackstageTheme } from '@backstage/theme'; - -const useStyles = makeStyles(theme => ({ - container: { - paddingTop: theme.spacing(24), - paddingLeft: theme.spacing(8), - [theme.breakpoints.down('xs')]: { - padding: theme.spacing(2), - }, - }, - title: { - paddingBottom: theme.spacing(2), - [theme.breakpoints.down('xs')]: { - fontSize: 32, - }, - }, - body: { - paddingBottom: theme.spacing(6), - [theme.breakpoints.down('xs')]: { - paddingBottom: theme.spacing(5), - }, - }, -})); - -export const TaskNotFound = () => { - const classes = useStyles(); - - return ( - - - - Task not found - - - No task found with this ID - - - - ); -}; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index fbf2a09a21..b2f1773d0b 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Page, Header, Lifecycle, Content } from '@backstage/core'; +import { Page, Header, Lifecycle, Content, ErrorPage } from '@backstage/core'; import React, { useState, useEffect, memo, useMemo } from 'react'; import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; import Stepper from '@material-ui/core/Stepper'; @@ -43,7 +43,6 @@ import Cancel from '@material-ui/icons/Cancel'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; import { entityRoute } from '@backstage/plugin-catalog-react'; import { parseEntityName } from '@backstage/catalog-model'; -import { TaskNotFound } from '../TaskNotFound/TaskNotFound'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -278,7 +277,11 @@ export const TaskPage = () => { /> {taskNotFound ? ( - + ) : (
From fd7e3ae8a21fb4ca2919250e63fb1cae831b887e Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Feb 2021 09:30:42 +0100 Subject: [PATCH 53/58] scaffolder: replace clsx with classNames --- plugins/scaffolder/package.json | 1 - plugins/scaffolder/src/components/TaskPage/TaskPage.tsx | 4 ++-- yarn.lock | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index f602f01b5b..9290d1d4bc 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -42,7 +42,6 @@ "@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", "humanize-duration": "^3.25.1", "luxon": "^1.25.0", diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index b2f1773d0b..d4f75e51af 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -37,12 +37,12 @@ import { import { Status } from '../../types'; import { DateTime, Interval } from 'luxon'; import { useInterval } from 'react-use'; -import clsx from 'clsx'; import Check from '@material-ui/icons/Check'; import Cancel from '@material-ui/icons/Cancel'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; import { entityRoute } from '@backstage/plugin-catalog-react'; import { parseEntityName } from '@backstage/catalog-model'; +import classNames from 'classnames'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -143,7 +143,7 @@ function TaskStepIconComponent(props: StepIconProps) { return (
Date: Wed, 17 Feb 2021 09:31:38 +0100 Subject: [PATCH 54/58] create-app: add missing semicolon --- .../default-app/packages/backend/src/plugins/scaffolder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index 84657948e5..6f42aaa327 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -29,7 +29,7 @@ export default async function createPlugin({ const dockerClient = new Docker(); const discovery = SingleHostDiscovery.fromConfig(config); - const catalogClient = new CatalogClient({ discoveryApi: discovery }) + const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ preparers, From 29257f31e0dae8a3bd46de68fe3b1f8456540a57 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Feb 2021 13:47:23 +0100 Subject: [PATCH 55/58] Scaffolder: Revert to Navigate component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- .../TemplatePage/TemplatePage.test.tsx | 18 ++++++++++++++---- .../components/TemplatePage/TemplatePage.tsx | 10 ++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index ae4a8943ae..f0c77f2f03 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -22,6 +22,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { MemoryRouter, Route } from 'react-router'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; +import { rootRouteRef } from '../../routes'; import { TemplatePage } from './TemplatePage'; const templateMock = { @@ -96,11 +97,15 @@ describe('TemplatePage', () => { , + { + mountedRoutes: { + '/create': rootRouteRef, + }, + }, ); expect(rendered.queryByText('Create a New Component')).toBeInTheDocument(); expect(rendered.queryByText('React SSR Template')).toBeInTheDocument(); - // await act(async () => await mutate('templates/test')); }); it('renders spinner while loading', async () => { @@ -113,13 +118,18 @@ describe('TemplatePage', () => { , + { + mountedRoutes: { + '/create': rootRouteRef, + }, + }, ); expect(rendered.queryByText('Create a New Component')).toBeInTheDocument(); expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument(); - // Need to cleanup the promise or will timeout - act(() => { - resolve!({ items: [] }); + + await act(async () => { + resolve!({ items: [templateMock] }); }); }); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 030cd5dccb..0fc1a3cf5d 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -29,7 +29,7 @@ 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, useNavigate } from 'react-router'; +import { generatePath, useNavigate, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; @@ -45,7 +45,7 @@ const useTemplate = ( filter: { kind: 'Template', 'metadata.name': templateName }, }); return response.items as TemplateEntityV1alpha1[]; - }); + }, [catalogApi, templateName]); return { template: value?.[0], loading, error }; }; @@ -100,8 +100,7 @@ export const TemplatePage = () => { if (!loading && !template) { errorApi.post(new Error('Template was not found.')); - navigate(rootLink()); - return <>{null}; + return ; } if (template && !template?.spec?.schema) { @@ -110,8 +109,7 @@ export const TemplatePage = () => { 'Template schema is corrupted, please check the template.yaml file.', ), ); - navigate(rootLink()); - return <>{null}; + return ; } return ( From 2cf720547c5e8c4e8185883b6159e8cf5f7e0f8d Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Feb 2021 14:14:00 +0100 Subject: [PATCH 56/58] =?UTF-8?q?Scaffolder:=20use=20colors=20from=20the?= =?UTF-8?q?=20Backstage=20theme=20Co-authored-by:=20Fredrik=20Adel=C3=B6w?= =?UTF-8?q?=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/TaskPage/TaskPage.tsx | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index d4f75e51af..96a857dbb5 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -43,6 +43,7 @@ import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; import { entityRoute } from '@backstage/plugin-catalog-react'; import { parseEntityName } from '@backstage/catalog-model'; import classNames from 'classnames'; +import { BackstageTheme } from '@backstage/theme'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -106,23 +107,22 @@ const StepTimeTicker = ({ step }: { step: TaskStep }) => { return {time}; }; -const useStepIconStyles = makeStyles({ - root: { - color: '#eaeaf0', - display: 'flex', - height: 22, - alignItems: 'center', - }, - active: { - color: 'gray', - }, - completed: { - color: 'green', - }, - error: { - color: 'red', - }, -}); +const useStepIconStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + root: { + color: theme.palette.text.disabled, + display: 'flex', + height: 22, + alignItems: 'center', + }, + completed: { + color: theme.palette.status.ok, + }, + error: { + color: theme.palette.status.error, + }, + }), +); function TaskStepIconComponent(props: StepIconProps) { const classes = useStepIconStyles(); @@ -144,7 +144,6 @@ function TaskStepIconComponent(props: StepIconProps) { return (
Date: Thu, 18 Feb 2021 14:11:13 +0100 Subject: [PATCH 57/58] scaffolder-backend: add router tests --- .../src/service/router.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index d8a236a213..8fd53e5fb6 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -185,6 +185,9 @@ describe('createRouter', () => { name: 'create-react-app-template', tags: ['experimental', 'react', 'cra'], title: 'Create React App Template', + annotations: { + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', + }, }, spec: { owner: 'web@example.com', @@ -247,4 +250,36 @@ describe('createRouter', () => { expect(response.status).toEqual(400); }); }); + + describe('POST /v2/tasks', () => { + it('rejects template values which do not match the template schema definition', async () => { + const response = await request(app) + .post('/v2/tasks') + .send({ + templateName: '', + values: { + storePath: 'https://github.com/backstage/backstage', + }, + }); + + expect(response.status).toEqual(400); + }); + + it('return the template id', async () => { + const response = await request(app) + .post('/v2/tasks') + .send({ + templateName: 'create-react-app-template', + values: { + storePath: 'https://github.com/backstage/backstage', + component_id: '123', + name: 'test', + use_typescript: false, + }, + }); + + expect(response.body.id).toBeDefined(); + expect(response.status).toEqual(201); + }); + }); }); From c1c3184b387a1c0e4351ed7f4dabc3f914a3ff7c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 19 Feb 2021 09:35:14 +0100 Subject: [PATCH 58/58] create-app: add catalog-client dependency --- packages/create-app/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 1bc3e845ce..b427b1e2ed 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -45,6 +45,7 @@ }, "peerDependencies": { "@backstage/backend-common": "^0.5.4", + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.1", "@backstage/cli": "^0.6.1", "@backstage/config": "^0.1.2",