From 470f72d8355d1cc2c1c1387bda806ebc765e2cc9 Mon Sep 17 00:00:00 2001 From: Tanish Sharma Date: Fri, 28 Nov 2025 09:12:32 +0000 Subject: [PATCH] feat: add log download btn for LogViewer Signed-off-by: Tanish Sharma --- .changeset/six-eels-film.md | 6 + packages/core-components/report-alpha.api.md | 1 + packages/core-components/report.api.md | 1 + .../src/components/LogViewer/LogViewer.tsx | 4 + .../LogViewer/LogViewerControls.tsx | 13 +- .../LogViewer/RealLogViewer.test.tsx | 37 ++++++ .../components/LogViewer/RealLogViewer.tsx | 7 +- packages/core-components/src/translation.ts | 3 + plugins/scaffolder-react/package.json | 1 + .../TaskLogStream/TaskLogStream.test.tsx | 112 ++++++++++++++++++ .../TaskLogStream/TaskLogStream.tsx | 5 + .../src/next/hooks/useDownloadLogs.ts | 39 ++++++ yarn.lock | 1 + 13 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 .changeset/six-eels-film.md create mode 100644 plugins/scaffolder-react/src/next/hooks/useDownloadLogs.ts diff --git a/.changeset/six-eels-film.md b/.changeset/six-eels-film.md new file mode 100644 index 0000000000..98bee78bf9 --- /dev/null +++ b/.changeset/six-eels-film.md @@ -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` diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index 785b97ce28..3b49564691 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -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'; } >; diff --git a/packages/core-components/report.api.md b/packages/core-components/report.api.md index 5e842929e6..8d3177323d 100644 --- a/packages/core-components/report.api.md +++ b/packages/core-components/report.api.md @@ -827,6 +827,7 @@ export interface LogViewerProps { classes?: { root?: string; }; + onDownloadLog?: () => void; text: string; textWrap?: boolean; } diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 26ddecfcfc..d0580d9191 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -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. * diff --git a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx index 461e058d2d..30eaee23fc 100644 --- a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx @@ -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) { )} + {Boolean(props?.onDownloadLog) ? ( + + + + + + ) : null} ); } diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx index 928bc77678..3d937cf32c 100644 --- a/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); + + const downloadButton = rendered.queryByRole('button', { + name: /download/i, + }); + expect(downloadButton).not.toBeInTheDocument(); + }); }); diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx index 1936061b1c..e1e2d71125 100644 --- a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx @@ -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 ( - + {shouldTextWrap ? ( diff --git a/packages/core-components/src/translation.ts b/packages/core-components/src/translation.ts index 3d0e62747e..1c063195c9 100644 --- a/packages/core-components/src/translation.ts +++ b/packages/core-components/src/translation.ts @@ -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', }, diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index 840f210c78..2c256ad3cc 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -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": { diff --git a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.test.tsx b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.test.tsx index 4466abdbd9..f2063068f1 100644 --- a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.test.tsx +++ b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.test.tsx @@ -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 inside 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(); + + 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(); + + // 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(); + + 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(); + }); }); diff --git a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx index b88d96510e..e8396167fc 100644 --- a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx +++ b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx @@ -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 (
l.join('\n')) .filter(Boolean) diff --git a/plugins/scaffolder-react/src/next/hooks/useDownloadLogs.ts b/plugins/scaffolder-react/src/next/hooks/useDownloadLogs.ts new file mode 100644 index 0000000000..1399e2f285 --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useDownloadLogs.ts @@ -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]); +}; diff --git a/yarn.lock b/yarn.lock index 7d27834ec3..f90fa64053 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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":