add TaskPage component
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user