feat: add log download btn for LogViewer

Signed-off-by: Tanish Sharma <tanish.sharma@siemens.com>
This commit is contained in:
Tanish Sharma
2025-11-28 09:12:32 +00:00
parent c8ae765724
commit 470f72d835
13 changed files with 228 additions and 2 deletions
+6
View File
@@ -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';
}
>;
+1
View File
@@ -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',
},
+1
View File
@@ -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]);
};
+1
View File
@@ -7084,6 +7084,7 @@ __metadata:
"@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:
"@types/react":