feat: add log download btn for LogViewer
Signed-off-by: Tanish Sharma <tanish.sharma@siemens.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-scaffolder-react': minor
|
||||
---
|
||||
|
||||
The `LogViewer` component from `@backstage/core-components` now supports downloading logs if a callback is passed to `onDownloadLogs`
|
||||
@@ -63,6 +63,7 @@ export const coreComponentsTranslationRef: TranslationRef<
|
||||
readonly 'autoLogout.stillTherePrompt.buttonText': "Yes! Don't log me out";
|
||||
readonly 'dependencyGraph.fullscreenTooltip': 'Toggle fullscreen';
|
||||
readonly 'proxiedSignInPage.title': 'You do not appear to be signed in. Please try reloading the browser page.';
|
||||
readonly 'logViewer.downloadBtn.tooltip': 'Download logs';
|
||||
readonly 'logViewer.searchField.placeholder': 'Search';
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -827,6 +827,7 @@ export interface LogViewerProps {
|
||||
classes?: {
|
||||
root?: string;
|
||||
};
|
||||
onDownloadLog?: () => void;
|
||||
text: string;
|
||||
textWrap?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ const RealLogViewer = lazy(() =>
|
||||
* @public
|
||||
*/
|
||||
export interface LogViewerProps {
|
||||
/**
|
||||
* Callback function to handle the download log action, and show the download button.
|
||||
*/
|
||||
onDownloadLog?: () => void;
|
||||
/**
|
||||
* The text of the logs to display.
|
||||
*
|
||||
|
||||
@@ -22,10 +22,14 @@ import Typography from '@material-ui/core/Typography';
|
||||
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
|
||||
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
import GetApp from '@material-ui/icons/GetApp';
|
||||
import ToolTip from '@material-ui/core/Tooltip';
|
||||
import { coreComponentsTranslationRef } from '../../translation';
|
||||
import { LogViewerSearch } from './useLogViewerSearch';
|
||||
|
||||
export interface LogViewerControlsProps extends LogViewerSearch {}
|
||||
export interface LogViewerControlsProps extends LogViewerSearch {
|
||||
onDownloadLog?: () => void;
|
||||
}
|
||||
|
||||
export function LogViewerControls(props: LogViewerControlsProps) {
|
||||
const { t } = useTranslationRef(coreComponentsTranslationRef);
|
||||
@@ -72,6 +76,13 @@ export function LogViewerControls(props: LogViewerControlsProps) {
|
||||
<FilterListIcon color="disabled" />
|
||||
)}
|
||||
</IconButton>
|
||||
{Boolean(props?.onDownloadLog) ? (
|
||||
<ToolTip title={t('logViewer.downloadBtn.tooltip')}>
|
||||
<IconButton size="small" onClick={props.onDownloadLog}>
|
||||
<GetApp />
|
||||
</IconButton>
|
||||
</ToolTip>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,4 +75,41 @@ describe('RealLogViewer', () => {
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('Derp');
|
||||
});
|
||||
|
||||
it('should render download button when showDownloadButton is true', async () => {
|
||||
const onDownloadLog = jest.fn();
|
||||
const rendered = await renderInTestApp(
|
||||
<RealLogViewer
|
||||
text={testText}
|
||||
showDownloadButton
|
||||
onDownloadLog={onDownloadLog}
|
||||
/>,
|
||||
);
|
||||
|
||||
const downloadButton = rendered.getByRole('button', { name: /download/i });
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(downloadButton);
|
||||
expect(onDownloadLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not render download button when showDownloadButton is false', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<RealLogViewer text={testText} showDownloadButton={false} />,
|
||||
);
|
||||
|
||||
const downloadButton = rendered.queryByRole('button', {
|
||||
name: /download/i,
|
||||
});
|
||||
expect(downloadButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render download button by default', async () => {
|
||||
const rendered = await renderInTestApp(<RealLogViewer text={testText} />);
|
||||
|
||||
const downloadButton = rendered.queryByRole('button', {
|
||||
name: /download/i,
|
||||
});
|
||||
expect(downloadButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useLogViewerSelection } from './useLogViewerSelection';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
|
||||
export interface RealLogViewerProps {
|
||||
showDownloadButton?: boolean;
|
||||
onDownloadLog?: () => void;
|
||||
text: string;
|
||||
textWrap?: boolean;
|
||||
classes?: { root?: string };
|
||||
@@ -187,7 +189,10 @@ export function RealLogViewer(props: RealLogViewerProps) {
|
||||
return (
|
||||
<Box style={{ width, height }} className={classes.root}>
|
||||
<Box className={classes.header}>
|
||||
<LogViewerControls {...search} />
|
||||
<LogViewerControls
|
||||
{...search}
|
||||
onDownloadLog={props.onDownloadLog}
|
||||
/>
|
||||
</Box>
|
||||
{shouldTextWrap ? (
|
||||
<VariableSizeList<AnsiLine[]>
|
||||
|
||||
@@ -128,6 +128,9 @@ export const coreComponentsTranslationRef = createTranslationRef({
|
||||
'You do not appear to be signed in. Please try reloading the browser page.',
|
||||
},
|
||||
logViewer: {
|
||||
downloadBtn: {
|
||||
tooltip: 'Download logs',
|
||||
},
|
||||
searchField: {
|
||||
placeholder: 'Search',
|
||||
},
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0",
|
||||
"react-router": "^6.30.3",
|
||||
"react-router-dom": "^6.30.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { ReactNode } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TaskLogStream } from './TaskLogStream';
|
||||
|
||||
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
|
||||
@@ -25,6 +26,22 @@ jest.mock('react-virtualized-auto-sizer', () => ({
|
||||
}) => <>{props.children({ width: 400, height: 200 })}</>,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Reflect.defineProperty(window.URL, 'createObjectURL', {
|
||||
writable: true,
|
||||
value: jest.fn((_blob: any) => 'blob:mock-url'),
|
||||
});
|
||||
Reflect.defineProperty(window.URL, 'revokeObjectURL', {
|
||||
writable: true,
|
||||
value: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(window.URL, 'createObjectURL');
|
||||
Reflect.deleteProperty(window.URL, 'revokeObjectURL');
|
||||
});
|
||||
|
||||
describe('TaskLogStream', () => {
|
||||
it('should render a log stream with the correct log lines', async () => {
|
||||
const logs = { step: ['line 1', 'line 2'], step2: ['line 3'] };
|
||||
@@ -47,4 +64,99 @@ describe('TaskLogStream', () => {
|
||||
|
||||
await expect(findAllByRole('row')).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render download button', async () => {
|
||||
const logs = { step: ['line 1', 'line 2'], step2: ['line 3'] };
|
||||
|
||||
const { getByRole } = await renderInTestApp(<TaskLogStream logs={logs} />);
|
||||
|
||||
const downloadButton = getByRole('button', { name: /download/i });
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should download logs when download button is clicked', async () => {
|
||||
const logs = {
|
||||
step1: ['line 1', 'line 2'],
|
||||
step2: ['line 3', 'line 4'],
|
||||
};
|
||||
|
||||
const { getByRole } = await renderInTestApp(<TaskLogStream logs={logs} />);
|
||||
|
||||
// Mock only the anchor element creation
|
||||
const mockAnchor = document.createElement('a');
|
||||
const clickSpy = jest.spyOn(mockAnchor, 'click');
|
||||
const removeSpy = jest.spyOn(mockAnchor, 'remove');
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
return mockAnchor;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
const downloadButton = getByRole('button', { name: /download/i });
|
||||
await userEvent.click(downloadButton);
|
||||
|
||||
// Verify file download was triggered
|
||||
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
|
||||
// Verify the download filename contains .log extension
|
||||
expect(mockAnchor.download).toMatch(/\.log$/);
|
||||
|
||||
// Verify href was set
|
||||
expect(mockAnchor.href).toContain('blob:');
|
||||
|
||||
// Restore mocks
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should create blob with correct log content when downloading', async () => {
|
||||
const logs = {
|
||||
step1: ['line 1', 'line 2'],
|
||||
step2: ['line 3'],
|
||||
};
|
||||
|
||||
const { getByRole } = await renderInTestApp(<TaskLogStream logs={logs} />);
|
||||
|
||||
let capturedBlob: Blob | null = null;
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockImplementation((blob: any) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:mock-url';
|
||||
});
|
||||
|
||||
const mockAnchor = document.createElement('a');
|
||||
const clickSpy = jest.spyOn(mockAnchor, 'click');
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
return mockAnchor;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
const downloadButton = getByRole('button', { name: /download/i });
|
||||
await userEvent.click(downloadButton);
|
||||
|
||||
// Verify file download was triggered
|
||||
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
|
||||
// Verify blob was created with correct content
|
||||
expect(capturedBlob).toBeInstanceOf(Blob);
|
||||
expect(capturedBlob!?.type).toBe('text/plain');
|
||||
|
||||
// Restore mocks
|
||||
createElementSpy.mockRestore();
|
||||
createObjectURLSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { LogViewer } from '@backstage/core-components';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { useDownloadLogs } from '../../hooks/useDownloadLogs';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -32,9 +33,13 @@ const useStyles = makeStyles({
|
||||
*/
|
||||
export const TaskLogStream = (props: { logs: { [k: string]: string[] } }) => {
|
||||
const styles = useStyles();
|
||||
|
||||
const onDownloadLogs = useDownloadLogs(props.logs);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<LogViewer
|
||||
onDownloadLog={onDownloadLogs}
|
||||
text={Object.values(props.logs)
|
||||
.map(l => l.join('\n'))
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 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 { useCallback } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
export const useDownloadLogs = (logs: { [k: string]: string[] }) => {
|
||||
const { taskId } = useParams<{ taskId: string }>();
|
||||
return useCallback(() => {
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob(
|
||||
[
|
||||
Object.values(logs)
|
||||
.map(l => l.join('\n'))
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
],
|
||||
{ type: 'text/plain' },
|
||||
);
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `${taskId}.log`;
|
||||
element.click();
|
||||
URL.revokeObjectURL(element.href);
|
||||
element.remove();
|
||||
}, [logs, taskId]);
|
||||
};
|
||||
Reference in New Issue
Block a user