add TaskPage component

This commit is contained in:
Johan Haals
2021-02-09 16:53:24 +01:00
parent 554209a063
commit 593251b69d
14 changed files with 373 additions and 270 deletions
+5
View File
@@ -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: <TemplatePage />,
})
.addPage({
path: '/scaffolder/tasks/:taskId',
element: <TaskPage />,
})
.render();
+2 -1
View File
@@ -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",
+7 -18
View File
@@ -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<ScaffolderApi>({
id: 'plugin.scaffolder.service',
@@ -27,6 +27,9 @@ export const scaffolderApiRef = createApiRef<ScaffolderApi>({
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<ScaffolderV2Task> {
async getTask(taskId: string): Promise<ScaffolderTask> {
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));
@@ -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) => {
</AccordionSummary>
<AccordionDetails className={classes.accordionDetails}>
{log.length === 0 ? (
<Box px={9} pb={2} width="100%">
No logs available for this step
</Box>
<div style={{ height: '20vh', width: '100%' }}>
<LazyLog
text="No logs available for this step..."
extraLines={1}
follow
/>
</div>
) : (
<Suspense fallback={<LinearProgress />}>
<LogModal
@@ -20,155 +20,16 @@ import {
DialogActions,
DialogContent,
DialogTitle,
LinearProgress,
} from '@material-ui/core';
import React, { useCallback, useEffect, useReducer } from 'react';
import { scaffolderApiRef } from '../../api';
import { ScaffolderV2Task } from '../../types';
type Props = {
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 = ({
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 (
<Dialog open={open} onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">{renderTitle()}</DialogTitle>
<DialogContent>
{/* {!task ? ( */}
<LinearProgress />
{/* ) : ( */}
{/* (task?.spec.steps ?? []).map(step => ( */}
{/* <JobStage */}
{/* log={step.log} */}
{/* name={step.name} */}
{/* key={step.name} */}
{/* startedAt={step.startedAt} */}
{/* endedAt={step.endedAt} */}
{/* status={step.status} */}
{/* /> */}
{/* )) */}
{/* )} */}
{task?.spec.steps
.filter(step => !!eventStream?.steps?.[step.id])
.map(step => (
<JobStage
log={eventStream?.steps?.[step.id].log ?? []}
name={step.name}
key={step.name}
startedAt={eventStream?.steps?.[step.id].startedAt}
endedAt={eventStream?.steps?.[step.id].endedAt}
status={eventStream?.steps?.[step.id].status ?? []}
/>
))}
</DialogContent>
{/* {job?.status && toCatalogLink && (
<DialogActions>
@@ -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 (
<div className={classes.root}>
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map((step, index) => (
<Step key={String(index)}>
<StepLabel error={taskStream.steps?.[step.id]?.status === 'failed'}>
{step.name}
</StepLabel>
<StepContent>
<div style={{ height: '50vh' }}>
<LazyLog
extraLines={1}
text={
taskStream.steps?.[step.id]?.log.length
? taskStream.steps?.[step.id]?.log.join('\n')
: 'fetching...'
}
/>
</div>
</StepContent>
</Step>
))}
</Stepper>
{activeStep === steps.length && (
<Paper square elevation={0} className={classes.resetContainer}>
<Typography>All steps completed - you&apos;re finished</Typography>
<Button onClick={handleReset} className={classes.button}>
Reset
</Button>
</Paper>
)}
</div>
);
};
export const TaskPage = () => {
const { taskId } = useParams();
const taskStream = useTaskEventStream(taskId);
return (
<Page themeId="home">
<Header
pageTitleOverride={`Task ${taskId}`}
title={
<>
Task Activity <Lifecycle alpha shorthand />
</>
}
subtitle={`Activity for task: ${taskId}`}
/>
<Content>
<TaskStepper taskStream={taskStream} />
</Content>
</Page>
);
};
@@ -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';
@@ -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<string | null>(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<ScaffolderTask | undefined>(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 && (
<InfoCard title={template.metadata.title} noPadding>
<MultistepJsonForm
@@ -0,0 +1,170 @@
/*
* 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 { useImmerReducer } from 'use-immer';
import { useEffect } from 'react';
import { scaffolderApiRef } from '../../api';
import { ScaffolderTask } from '../../types';
import { Subscription, useApi } from '@backstage/core';
type Status = 'open' | 'processing' | 'failed' | 'completed';
type Step = {
id: string;
status: Status;
log: string[];
endedAt?: string;
startedAt?: string;
};
export type TaskStream = {
loading: boolean;
error?: Error;
log: string[];
completed: boolean;
task?: ScaffolderTask;
steps?: { [stepId in string]: Step };
};
type ReducerAction =
| {
type: 'INIT';
data: ScaffolderTask;
}
| {
type: 'LOG';
data: {
createdAt: string;
body: { stepId?: string; status?: Status; message: string };
};
}
| { type: 'COMPLETED' }
| { type: 'ERROR'; data: Error };
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 };
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;
};
@@ -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<ScaffolderV2Task | null>(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;
};
+2 -1
View File
@@ -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';
+11 -1
View File
@@ -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,
}),
);
+5
View File
@@ -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',
});
+3 -3
View File
@@ -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;