Provide a Drawer component to follow a running build

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-07-05 12:48:12 +02:00
parent f1200f44c8
commit 3af126cddf
9 changed files with 366 additions and 130 deletions
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs': patch
---
Provide a Drawer component to follow a running build.
This can be used to debug the rendering and get build logs in case an error occurs.
+1
View File
@@ -47,6 +47,7 @@
"eventsource": "^1.1.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
@@ -15,10 +15,11 @@
*/
import { EntityName } from '@backstage/catalog-model';
import { Progress } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { BackstageTheme } from '@backstage/theme';
import { useTheme } from '@material-ui/core';
import { CircularProgress, useTheme } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -35,8 +36,8 @@ import {
simplifyMkdocsFooter,
transform as transformer,
} from '../transformers';
import { TechDocsBuildLogs } from './TechDocsBuildLogs';
import { TechDocsNotFound } from './TechDocsNotFound';
import TechDocsProgressBar from './TechDocsProgressBar';
import { useReaderState } from './useReaderState';
type Props = {
@@ -49,7 +50,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
const { '*': path } = useParams();
const theme = useTheme<BackstageTheme>();
const { state, content: rawPage, errorMessage } = useReaderState(
const { state, content: rawPage, errorMessage, buildLog } = useReaderState(
kind,
namespace,
name,
@@ -313,11 +314,25 @@ export const Reader = ({ entityId, onReady }: Props) => {
return (
<>
{(state === 'CHECKING' || state === 'INITIAL_BUILD') && (
<TechDocsProgressBar />
{state === 'CHECKING' && <Progress />}
{state === 'INITIAL_BUILD' && (
<Alert
variant="outlined"
severity="info"
icon={<CircularProgress size="24px" />}
action={<TechDocsBuildLogs buildLog={buildLog} />}
>
Documentation is accessed for the first time and is being prepared.
The subsequent loads are much faster.
</Alert>
)}
{state === 'CONTENT_STALE_REFRESHING' && (
<Alert variant="outlined" severity="info">
<Alert
variant="outlined"
severity="info"
icon={<CircularProgress size="24px" />}
action={<TechDocsBuildLogs buildLog={buildLog} />}
>
A newer version of this documentation is being prepared and will be
available shortly.
</Alert>
@@ -329,7 +344,11 @@ export const Reader = ({ entityId, onReady }: Props) => {
</Alert>
)}
{state === 'CONTENT_STALE_ERROR' && (
<Alert variant="outlined" severity="error">
<Alert
variant="outlined"
severity="error"
action={<TechDocsBuildLogs buildLog={buildLog} />}
>
Building a newer version of this documentation failed. {errorMessage}
</Alert>
)}
@@ -0,0 +1,87 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { render } from '@testing-library/react';
import React from 'react';
import {
TechDocsBuildLogs,
TechDocsBuildLogsDrawerContent,
} from './TechDocsBuildLogs';
// react-lazylog is based on a react-virtualized component which doesn't
// write the content to the dom, so we mock it.
jest.mock('react-lazylog', () => {
return {
LazyLog: ({ text }: { text: string }) => {
return <p>{text}</p>;
},
};
});
describe('<TechDocsBuildLogs />', () => {
it('should render with button', () => {
const rendered = render(<TechDocsBuildLogs buildLog={[]} />);
expect(rendered.getByText(/Show Build Logs/i)).toBeInTheDocument();
expect(rendered.queryByText(/Build Details/i)).not.toBeInTheDocument();
});
it('should open drawer', () => {
const rendered = render(<TechDocsBuildLogs buildLog={[]} />);
rendered.getByText(/Show Build Logs/i).click();
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
});
});
describe('<TechDocsBuildLogsDrawerContent />', () => {
it('should render with empty log', () => {
const onClose = jest.fn();
const rendered = render(
<TechDocsBuildLogsDrawerContent buildLog={[]} onClose={onClose} />,
);
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
expect(rendered.getByText(/Waiting for logs.../i)).toBeInTheDocument();
expect(onClose).toBeCalledTimes(0);
});
it('should render with empty logs', () => {
const onClose = jest.fn();
const rendered = render(
<TechDocsBuildLogsDrawerContent
buildLog={['Line 1', 'Line 2']}
onClose={onClose}
/>,
);
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
expect(
rendered.queryByText(/Waiting for logs.../i),
).not.toBeInTheDocument();
expect(rendered.getByText(/Line 1/i)).toBeInTheDocument();
expect(rendered.getByText(/Line 2/i)).toBeInTheDocument();
expect(onClose).toBeCalledTimes(0);
});
it('should call onClose', () => {
const onClose = jest.fn();
const rendered = render(
<TechDocsBuildLogsDrawerContent buildLog={[]} onClose={onClose} />,
);
rendered.getByRole('button').click();
expect(onClose).toBeCalledTimes(1);
});
});
@@ -0,0 +1,121 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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,
createStyles,
Drawer,
Grid,
IconButton,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import Close from '@material-ui/icons/Close';
import * as React from 'react';
import { useState } from 'react';
import { LazyLog } from 'react-lazylog';
const useDrawerStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
width: '100%',
[theme.breakpoints.up('sm')]: {
width: '75%',
},
[theme.breakpoints.up('md')]: {
width: '50%',
},
padding: theme.spacing(2.5),
},
root: {
height: '100%',
overflow: 'hidden',
},
}),
);
export const TechDocsBuildLogsDrawerContent = ({
buildLog,
onClose,
}: {
buildLog: string[];
onClose: () => void;
}) => {
const classes = useDrawerStyles();
return (
<Grid
container
direction="column"
className={classes.root}
spacing={0}
wrap="nowrap"
>
<Grid
item
container
justify="space-between"
alignItems="center"
spacing={0}
wrap="nowrap"
>
<Typography variant="h5">Build Details</Typography>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={onClose}
color="inherit"
>
<Close />
</IconButton>
</Grid>
<LazyLog
text={
buildLog.length === 0 ? 'Waiting for logs...' : buildLog.join('\n')
}
extraLines={1}
follow
selectableLines
enableSearch
/>
</Grid>
);
};
export const TechDocsBuildLogs = ({ buildLog }: { buildLog: string[] }) => {
const classes = useDrawerStyles();
const [open, setOpen] = useState(false);
return (
<>
<Button color="inherit" onClick={() => setOpen(true)}>
Show Build Logs
</Button>
<Drawer
classes={{ paper: classes.paper }}
anchor="right"
open={open}
onClose={() => setOpen(false)}
>
<TechDocsBuildLogsDrawerContent
buildLog={buildLog}
onClose={() => setOpen(false)}
/>
</Drawer>
</>
);
};
@@ -1,38 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 TechDocsProgressBar from './TechDocsProgressBar';
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { act } from 'react-dom/test-utils';
jest.useFakeTimers();
describe('<TechDocsProgressBar />', () => {
it('should render a message if techdocs page takes more time to load', () => {
const rendered = render(wrapInTestApp(<TechDocsProgressBar />));
act(() => {
jest.advanceTimersByTime(250);
});
expect(rendered.getByTestId('progress')).toBeInTheDocument();
expect(rendered.queryByTestId('delay-reason')).toBeNull();
act(() => {
jest.advanceTimersByTime(5000);
});
expect(rendered.getByTestId('delay-reason')).toBeInTheDocument();
});
});
@@ -1,50 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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, { useState, useEffect } from 'react';
import { useMountedState } from 'react-use';
import { Typography } from '@material-ui/core';
import { Progress } from '@backstage/core-components';
const TechDocsProgressBar = () => {
const isMounted = useMountedState();
const [hasBeenDelayed, setHasBeenDelayed] = useState(false);
const delayReason = `Docs are still loading...Backstage takes some extra time to load docs
for the first time. The subsequent loads are much faster.`;
// Allowed time that docs can take to load (in milliseconds)
const allowedDelayTime = 5000;
useEffect(() => {
setTimeout(() => {
if (isMounted()) {
setHasBeenDelayed(true);
}
}, allowedDelayTime);
});
return (
<>
{hasBeenDelayed ? (
<Typography data-testid="delay-reason">{delayReason}</Typography>
) : null}
<Progress />
</>
);
};
export default TechDocsProgressBar;
@@ -82,6 +82,7 @@ describe('useReaderState', () => {
activeSyncState: 'CHECKING',
contentLoading: false,
path: '',
buildLog: ['1', '2'],
};
it('should return a copy of the state', () => {
@@ -89,12 +90,14 @@ describe('useReaderState', () => {
activeSyncState: 'CHECKING',
contentLoading: false,
path: '/',
buildLog: ['1', '2'],
});
expect(oldState).toEqual({
activeSyncState: 'CHECKING',
contentLoading: false,
path: '',
buildLog: ['1', '2'],
});
});
@@ -208,6 +211,33 @@ describe('useReaderState', () => {
activeSyncState: 'BUILDING',
});
});
it('should clear buildLog on "CHECKING"', () => {
expect(
reducer(oldState, {
type: 'sync',
state: 'CHECKING',
}),
).toEqual({
...oldState,
activeSyncState: 'CHECKING',
buildLog: [],
});
});
});
describe('"buildLog" action', () => {
it('should work', () => {
expect(
reducer(oldState, {
type: 'buildLog',
log: 'Another Line',
}),
).toEqual({
...oldState,
buildLog: ['1', '2', 'Another Line'],
});
});
});
});
@@ -228,6 +258,7 @@ describe('useReaderState', () => {
state: 'CHECKING',
content: undefined,
errorMessage: '',
buildLog: [],
});
await waitForValueToChange(() => result.current.state);
@@ -236,17 +267,21 @@ describe('useReaderState', () => {
state: 'CONTENT_FRESH',
content: 'my content',
errorMessage: '',
buildLog: [],
});
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
kind: 'Component',
namespace: 'default',
name: 'backstage',
});
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
});
@@ -257,10 +292,14 @@ describe('useReaderState', () => {
await new Promise(resolve => setTimeout(resolve, 500));
return 'my content';
});
techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
return 'updated';
});
techdocsStorageApi.syncEntityDocs.mockImplementation(
async (_, logHandler) => {
logHandler?.call(this, 'Line 1');
logHandler?.call(this, 'Line 2');
await new Promise(resolve => setTimeout(resolve, 1100));
return 'updated';
},
);
await act(async () => {
const { result, waitForValueToChange } = await renderHook(
@@ -272,6 +311,7 @@ describe('useReaderState', () => {
state: 'CHECKING',
content: undefined,
errorMessage: '',
buildLog: [],
});
await waitForValueToChange(() => result.current.state);
@@ -280,6 +320,7 @@ describe('useReaderState', () => {
state: 'INITIAL_BUILD',
content: undefined,
errorMessage: ' Load error: NotFoundError: Page Not Found',
buildLog: ['Line 1', 'Line 2'],
});
await waitForValueToChange(() => result.current.state);
@@ -288,6 +329,7 @@ describe('useReaderState', () => {
state: 'CHECKING',
content: undefined,
errorMessage: '',
buildLog: [],
});
await waitForValueToChange(() => result.current.state);
@@ -296,6 +338,7 @@ describe('useReaderState', () => {
state: 'CONTENT_FRESH',
content: 'my content',
errorMessage: '',
buildLog: [],
});
expect(techdocsStorageApi.getEntityDocs).toBeCalledTimes(2);
@@ -304,20 +347,27 @@ describe('useReaderState', () => {
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toBeCalledTimes(1);
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
kind: 'Component',
namespace: 'default',
name: 'backstage',
});
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
});
it('should handle stale content', async () => {
techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
return 'updated';
});
techdocsStorageApi.syncEntityDocs.mockImplementation(
async (_, logHandler) => {
logHandler?.call(this, 'Line 1');
logHandler?.call(this, 'Line 2');
await new Promise(resolve => setTimeout(resolve, 1100));
return 'updated';
},
);
await act(async () => {
const { result, waitForValueToChange } = await renderHook(
@@ -329,6 +379,7 @@ describe('useReaderState', () => {
state: 'CHECKING',
content: undefined,
errorMessage: '',
buildLog: [],
});
// the content is returned but the sync is in progress
@@ -337,6 +388,7 @@ describe('useReaderState', () => {
state: 'CONTENT_FRESH',
content: 'my content',
errorMessage: '',
buildLog: ['Line 1', 'Line 2'],
});
// the sync takes longer than 1 seconds so the refreshing state starts
@@ -345,6 +397,7 @@ describe('useReaderState', () => {
state: 'CONTENT_STALE_REFRESHING',
content: 'my content',
errorMessage: '',
buildLog: ['Line 1', 'Line 2'],
});
// the content is up-to-date
@@ -353,17 +406,21 @@ describe('useReaderState', () => {
state: 'CONTENT_STALE_READY',
content: 'my content',
errorMessage: '',
buildLog: ['Line 1', 'Line 2'],
});
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
kind: 'Component',
namespace: 'default',
name: 'backstage',
});
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
});
@@ -383,6 +440,7 @@ describe('useReaderState', () => {
state: 'CHECKING',
content: undefined,
errorMessage: '',
buildLog: [],
});
// the content loading threw an error
@@ -391,17 +449,21 @@ describe('useReaderState', () => {
state: 'CONTENT_NOT_FOUND',
content: undefined,
errorMessage: ' Load error: NotFoundError: Some error description',
buildLog: [],
});
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
kind: 'Component',
namespace: 'default',
name: 'backstage',
});
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
});
});
@@ -137,7 +137,8 @@ type ReducerActions =
contentLoading?: true;
contentError?: Error;
}
| { type: 'navigate'; path: string };
| { type: 'navigate'; path: string }
| { type: 'buildLog'; log: string };
type ReducerState = {
/**
@@ -161,6 +162,11 @@ type ReducerState = {
contentError?: Error;
syncError?: Error;
/**
* A list of log messages that were emitted by the build process.
*/
buildLog: string[];
};
export function reducer(
@@ -171,6 +177,11 @@ export function reducer(
switch (action.type) {
case 'sync':
// reset the build log when a new check starts
if (action.state === 'CHECKING') {
newState.buildLog = [];
}
newState.activeSyncState = action.state;
newState.syncError = action.syncError;
break;
@@ -185,6 +196,10 @@ export function reducer(
newState.path = action.path;
break;
case 'buildLog':
newState.buildLog = newState.buildLog.concat(action.log);
break;
default:
throw new Error();
}
@@ -195,6 +210,7 @@ export function reducer(
['content', 'navigate'].includes(action.type)
) {
newState.activeSyncState = 'UP_TO_DATE';
newState.buildLog = [];
}
return newState;
@@ -205,11 +221,17 @@ export function useReaderState(
namespace: string,
name: string,
path: string,
): { state: ContentStateTypes; content?: string; errorMessage?: string } {
): {
state: ContentStateTypes;
content?: string;
errorMessage?: string;
buildLog: string[];
} {
const [state, dispatch] = useReducer(reducer, {
activeSyncState: 'CHECKING',
path,
contentLoading: true,
buildLog: [],
});
const techdocsStorageApi = useApi(techdocsStorageApiRef);
@@ -257,11 +279,16 @@ export function useReaderState(
}, 1000);
try {
const result = await techdocsStorageApi.syncEntityDocs({
kind,
namespace,
name,
});
const result = await techdocsStorageApi.syncEntityDocs(
{
kind,
namespace,
name,
},
log => {
dispatch({ type: 'buildLog', log });
},
);
switch (result) {
case 'updated':
@@ -317,5 +344,6 @@ export function useReaderState(
state: displayState,
content: state.content,
errorMessage,
buildLog: state.buildLog,
};
}