From 593251b69dd7e2794a5d7d9f95264fdf1517a746 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 9 Feb 2021 16:53:24 +0100 Subject: [PATCH] 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;