diff --git a/.changeset/techdocs-lovely-bottles-beam.md b/.changeset/techdocs-lovely-bottles-beam.md new file mode 100644 index 0000000000..3f89396c79 --- /dev/null +++ b/.changeset/techdocs-lovely-bottles-beam.md @@ -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. diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 1e67549e23..1b1c488429 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -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", diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index cc76d789b6..f8938cf8d8 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -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(); - 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') && ( - + {state === 'CHECKING' && } + {state === 'INITIAL_BUILD' && ( + } + action={} + > + Documentation is accessed for the first time and is being prepared. + The subsequent loads are much faster. + )} {state === 'CONTENT_STALE_REFRESHING' && ( - + } + action={} + > A newer version of this documentation is being prepared and will be available shortly. @@ -329,7 +344,11 @@ export const Reader = ({ entityId, onReady }: Props) => { )} {state === 'CONTENT_STALE_ERROR' && ( - + } + > Building a newer version of this documentation failed. {errorMessage} )} diff --git a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.test.tsx b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.test.tsx new file mode 100644 index 0000000000..d1bb99b76c --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.test.tsx @@ -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

{text}

; + }, + }; +}); + +describe('', () => { + it('should render with button', () => { + const rendered = render(); + expect(rendered.getByText(/Show Build Logs/i)).toBeInTheDocument(); + expect(rendered.queryByText(/Build Details/i)).not.toBeInTheDocument(); + }); + + it('should open drawer', () => { + const rendered = render(); + rendered.getByText(/Show Build Logs/i).click(); + expect(rendered.getByText(/Build Details/i)).toBeInTheDocument(); + }); +}); + +describe('', () => { + it('should render with empty log', () => { + const onClose = jest.fn(); + const rendered = render( + , + ); + 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( + , + ); + 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( + , + ); + rendered.getByRole('button').click(); + + expect(onClose).toBeCalledTimes(1); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx new file mode 100644 index 0000000000..d9da954152 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx @@ -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 ( + + + Build Details + + + + + + + + ); +}; + +export const TechDocsBuildLogs = ({ buildLog }: { buildLog: string[] }) => { + const classes = useDrawerStyles(); + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + > + setOpen(false)} + /> + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx b/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx deleted file mode 100644 index f33ed62b97..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx +++ /dev/null @@ -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('', () => { - it('should render a message if techdocs page takes more time to load', () => { - const rendered = render(wrapInTestApp()); - 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(); - }); -}); diff --git a/plugins/techdocs/src/reader/components/TechDocsProgressBar.tsx b/plugins/techdocs/src/reader/components/TechDocsProgressBar.tsx deleted file mode 100644 index 7089f5ae08..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsProgressBar.tsx +++ /dev/null @@ -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 ? ( - {delayReason} - ) : null} - - - ); -}; - -export default TechDocsProgressBar; diff --git a/plugins/techdocs/src/reader/components/useReaderState.test.tsx b/plugins/techdocs/src/reader/components/useReaderState.test.tsx index d3f5362a07..3a26f91279 100644 --- a/plugins/techdocs/src/reader/components/useReaderState.test.tsx +++ b/plugins/techdocs/src/reader/components/useReaderState.test.tsx @@ -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), + ); }); }); }); diff --git a/plugins/techdocs/src/reader/components/useReaderState.ts b/plugins/techdocs/src/reader/components/useReaderState.ts index be47ee301d..a2e70b5a38 100644 --- a/plugins/techdocs/src/reader/components/useReaderState.ts +++ b/plugins/techdocs/src/reader/components/useReaderState.ts @@ -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, }; }