forked from podman-desktop/podman-desktop
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add gather & download logs button in troubleshooting (podman-de…
…sktop#5119) * feat: Add gather & download logs button in troubleshooting ### What does this PR do? * Adds a button to collect logs which is currently only hardcoded to retrieve the console logs * Adds a button to download the logs as a zip file with all logs stored within as a .txt file * Shows which files have been generated and what will be zipped. ### Screenshot / video of UI <!-- If this PR is changing UI, please include screenshots or screencasts showing the difference --> ### What issues does this PR fix or reference? <!-- Include any related issues from Podman Desktop repository (or from another issue tracker). --> Closes podman-desktop#5048 ### How to test this PR? <!-- Please explain steps to reproduce --> 1. Go to troubleshooting 2. Click the collect logs & download logs buttons. Signed-off-by: Charlie Drage <[email protected]> * Update packages/main/src/plugin/troubleshooting.ts Co-authored-by: Florent BENOIT <[email protected]> Signed-off-by: Charlie Drage <[email protected]> * Update packages/main/src/plugin/troubleshooting.ts Co-authored-by: Florent BENOIT <[email protected]> Signed-off-by: Charlie Drage <[email protected]> * Update packages/main/src/plugin/troubleshooting.spec.ts Co-authored-by: Florent BENOIT <[email protected]> Signed-off-by: Charlie Drage <[email protected]> * Update packages/main/src/plugin/troubleshooting.ts Co-authored-by: Florent BENOIT <[email protected]> Signed-off-by: Charlie Drage <[email protected]> * Update packages/renderer/src/lib/troubleshooting/TroubleshootingGatherLogs.svelte Co-authored-by: Florent BENOIT <[email protected]> Signed-off-by: Charlie Drage <[email protected]> * Update packages/renderer/src/lib/troubleshooting/TroubleshootingGatherLogs.svelte Co-authored-by: Florent BENOIT <[email protected]> Signed-off-by: Charlie Drage <[email protected]> * Update packages/main/src/plugin/troubleshooting.ts Co-authored-by: Florent BENOIT <[email protected]> Signed-off-by: Charlie Drage <[email protected]> * update based on review changes Signed-off-by: Charlie Drage <[email protected]> --------- Signed-off-by: Charlie Drage <[email protected]> Co-authored-by: Florent BENOIT <[email protected]>
- Loading branch information
Showing
7 changed files
with
457 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
/********************************************************************** | ||
* Copyright (C) 2024 Red Hat, Inc. | ||
* | ||
* 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. | ||
* | ||
* SPDX-License-Identifier: Apache-2.0 | ||
***********************************************************************/ | ||
|
||
import { beforeEach, expect, test, vi } from 'vitest'; | ||
import { Troubleshooting } from './troubleshooting.js'; | ||
import type { TroubleshootingFileMap, LogType } from './troubleshooting.js'; | ||
import * as fs from 'node:fs'; | ||
import type { ApiSenderType } from './api.js'; | ||
|
||
const writeZipMock = vi.fn(); | ||
const addFileMock = vi.fn(); | ||
|
||
const apiSender: ApiSenderType = { | ||
send: vi.fn(), | ||
receive: vi.fn(), | ||
}; | ||
|
||
vi.mock('electron', () => { | ||
return { | ||
ipcMain: { | ||
emit: vi.fn(), | ||
on: vi.fn(), | ||
}, | ||
}; | ||
}); | ||
|
||
vi.mock('adm-zip', () => { | ||
return { | ||
default: class { | ||
addFile = addFileMock; | ||
writeZip = writeZipMock; | ||
}, | ||
}; | ||
}); | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
// Test the saveLogsToZip function | ||
test('Should save a zip file with the correct content', async () => { | ||
const zipFile = new Troubleshooting(apiSender); | ||
const fileMaps = [ | ||
{ | ||
filename: 'file1', | ||
content: 'content1', | ||
}, | ||
{ | ||
filename: 'file2', | ||
content: 'content2', | ||
}, | ||
]; | ||
|
||
const zipSpy = vi.spyOn(zipFile, 'saveLogsToZip'); | ||
|
||
await zipFile.saveLogsToZip(fileMaps, 'test.zip'); | ||
expect(zipSpy).toHaveBeenCalledWith(fileMaps, 'test.zip'); | ||
|
||
expect(writeZipMock).toHaveBeenCalledWith('test.zip'); | ||
}); | ||
|
||
// Do not expect writeZipMock to have been called if fileMaps is empty | ||
test('Should not save a zip file if fileMaps is empty', async () => { | ||
const zipFile = new Troubleshooting(apiSender); | ||
const fileMaps: TroubleshootingFileMap[] = []; | ||
|
||
const zipSpy = vi.spyOn(zipFile, 'saveLogsToZip'); | ||
|
||
await zipFile.saveLogsToZip(fileMaps, 'test.zip'); | ||
expect(zipSpy).toHaveBeenCalledWith(fileMaps, 'test.zip'); | ||
|
||
expect(writeZipMock).not.toHaveBeenCalled(); | ||
}); | ||
|
||
// Expect the file name to have a .txt extension | ||
test('Should have a .txt extension in the file name', async () => { | ||
const zipFile = new Troubleshooting(apiSender); | ||
const fileMaps = [ | ||
{ | ||
filename: 'file1', | ||
content: '', | ||
}, | ||
{ | ||
filename: 'file2', | ||
content: '', | ||
}, | ||
]; | ||
|
||
const zipSpy = vi.spyOn(zipFile, 'saveLogsToZip'); | ||
|
||
await zipFile.saveLogsToZip(fileMaps, 'test.zip'); | ||
expect(zipSpy).toHaveBeenCalledWith(fileMaps, 'test.zip'); | ||
|
||
expect(addFileMock).toHaveBeenCalledWith('file1', expect.any(Object)); | ||
expect(addFileMock).toHaveBeenCalledWith('file2', expect.any(Object)); | ||
}); | ||
|
||
// Expect getConsoleLogs to correctly format the console logs passed in | ||
test('Should correctly format console logs', async () => { | ||
const zipFile = new Troubleshooting(apiSender); | ||
const consoleLogs = [ | ||
{ | ||
logType: 'log' as LogType, | ||
date: new Date(), | ||
message: 'message1', | ||
}, | ||
{ | ||
logType: 'log' as LogType, | ||
date: new Date(), | ||
message: 'message2', | ||
}, | ||
]; | ||
|
||
const zipSpy = vi.spyOn(zipFile, 'getConsoleLogs'); | ||
|
||
const fileMaps = zipFile.getConsoleLogs(consoleLogs); | ||
expect(zipSpy).toHaveBeenCalledWith(consoleLogs); | ||
|
||
expect(fileMaps[0].filename).toContain('console'); | ||
expect(fileMaps[0].content).toContain('log : message1'); | ||
expect(fileMaps[0].content).toContain('log : message2'); | ||
}); | ||
|
||
// Expect getSystemLogs to return getMacSystemLogs if the platform is darwin | ||
// mock the private getMacSystemLogs function so we can spy on it | ||
test('Should return getMacSystemLogs if the platform is darwin', async () => { | ||
// Mock platform to be darwin | ||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); | ||
|
||
const readFileMock = vi.spyOn(fs.promises, 'readFile'); | ||
readFileMock.mockResolvedValue('content'); | ||
|
||
// Mock exists to be true | ||
vi.mock('node:fs'); | ||
vi.spyOn(fs, 'existsSync').mockImplementation(() => { | ||
return true; | ||
}); | ||
|
||
const zipFile = new Troubleshooting(apiSender); | ||
const getSystemLogsSpy = vi.spyOn(zipFile, 'getSystemLogs'); | ||
|
||
await zipFile.getSystemLogs(); | ||
expect(getSystemLogsSpy).toHaveBeenCalled(); | ||
|
||
// Expect it to have been called twice as it checked stdout and stderr | ||
expect(readFileMock).toHaveBeenCalledTimes(2); | ||
|
||
// Expect readFileMock to have been called with /Library/Logs/Podman Desktop/launchd-stdout.log but CONTAINED in the path | ||
expect(readFileMock).toHaveBeenCalledWith( | ||
expect.stringContaining('/Library/Logs/Podman Desktop/launchd-stdout'), | ||
'utf-8', | ||
); | ||
expect(readFileMock).toHaveBeenCalledWith( | ||
expect.stringContaining('/Library/Logs/Podman Desktop/launchd-stderr'), | ||
'utf-8', | ||
); | ||
}); | ||
|
||
// Should return getWindowsSystemLogs if the platform is win32 | ||
// ~/AppData/Roaming/Podman Desktop/logs/podman-desktop.log | ||
test('Should return getWindowsSystemLogs if the platform is win32', async () => { | ||
// Mock exists to be true | ||
vi.mock('node:fs'); | ||
vi.spyOn(fs, 'existsSync').mockImplementation(() => { | ||
return true; | ||
}); | ||
|
||
// Mock platform to be win32 | ||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); | ||
|
||
const readFileMock = vi.spyOn(fs.promises, 'readFile'); | ||
readFileMock.mockResolvedValue('content'); | ||
|
||
const zipFile = new Troubleshooting(apiSender); | ||
const getSystemLogsSpy = vi.spyOn(zipFile, 'getSystemLogs'); | ||
|
||
await zipFile.getSystemLogs(); | ||
expect(getSystemLogsSpy).toHaveBeenCalled(); | ||
|
||
// Expect it to have been called once as it checked podman-desktop.log | ||
expect(readFileMock).toHaveBeenCalledTimes(1); | ||
|
||
// Expect readFileMock to have been called with ~/AppData/Roaming/Podman Desktop/logs/podman-desktop.log but CONTAINED in the path | ||
expect(readFileMock).toHaveBeenCalledWith( | ||
expect.stringContaining('/AppData/Roaming/Podman Desktop/logs/podman-desktop'), | ||
'utf-8', | ||
); | ||
}); | ||
|
||
test('test generateLogFileName', async () => { | ||
const ts = new Troubleshooting(apiSender); | ||
const filename = ts.generateLogFileName('test'); | ||
|
||
// Simple regex to check that the file name is in the correct format (YYYMMDDHHmmss) | ||
expect(filename).toMatch(/[0-9]{14}/); | ||
expect(filename).toContain('test'); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
/********************************************************************** | ||
* Copyright (C) 2024 Red Hat, Inc. | ||
* | ||
* 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. | ||
* | ||
* SPDX-License-Identifier: Apache-2.0 | ||
***********************************************************************/ | ||
|
||
import AdmZip from 'adm-zip'; | ||
import moment from 'moment'; | ||
import * as os from 'node:os'; | ||
import * as fs from 'node:fs'; | ||
import type { ApiSenderType } from './api.js'; | ||
|
||
const SYSTEM_FILENAME = 'system'; | ||
|
||
export interface TroubleshootingFileMap { | ||
filename: string; | ||
content: string; | ||
} | ||
|
||
export type LogType = 'log' | 'warn' | 'trace' | 'debug' | 'error'; | ||
|
||
export class Troubleshooting { | ||
constructor(private apiSender: ApiSenderType) {} | ||
|
||
// The "main" function that is exposes that is used to gather | ||
// all the logs and save them to a zip file. | ||
// this also takes in the console logs and adds them to the zip file (see preload/src/index.ts) regarding memoryLogs | ||
async saveLogs(console: { logType: LogType; message: string }[], destination: string): Promise<string[]> { | ||
const systemLogs = await this.getSystemLogs(); | ||
const consoleLogs = this.getConsoleLogs(console); | ||
const fileMaps = [...systemLogs, ...consoleLogs]; | ||
await this.saveLogsToZip(fileMaps, destination); | ||
return fileMaps.map(fileMap => fileMap.filename); | ||
} | ||
|
||
async saveLogsToZip(fileMaps: TroubleshootingFileMap[], destination: string): Promise<void> { | ||
if (fileMaps.length === 0) { | ||
return; | ||
} | ||
|
||
const zip = new AdmZip(); | ||
fileMaps.forEach(fileMap => { | ||
zip.addFile(fileMap.filename, Buffer.from(fileMap.content, 'utf8')); | ||
}); | ||
zip.writeZip(destination); | ||
} | ||
|
||
getConsoleLogs(consoleLogs: { logType: LogType; message: string }[]): TroubleshootingFileMap[] { | ||
const content = consoleLogs.map(log => `${log.logType} : ${log.message}`).join('\n'); | ||
return [{ filename: this.generateLogFileName('console'), content }]; | ||
} | ||
|
||
async getSystemLogs(): Promise<TroubleshootingFileMap[]> { | ||
switch (os.platform()) { | ||
case 'darwin': | ||
return this.getLogsFromFiles( | ||
['launchd-stdout.log', 'launchd-stderr.log'], | ||
`${os.homedir()}/Library/Logs/Podman Desktop`, | ||
); | ||
case 'win32': | ||
return this.getLogsFromFiles(['podman-desktop'], `${os.homedir()}/AppData/Roaming/Podman Desktop/logs`); | ||
default: | ||
// Unsupported platform, so do not return anything | ||
return []; | ||
} | ||
} | ||
|
||
private async getFileSystemContent(filePath: string, logName: string): Promise<TroubleshootingFileMap> { | ||
const content = await fs.promises.readFile(filePath, 'utf-8'); | ||
return { filename: this.generateLogFileName(SYSTEM_FILENAME + '-' + logName), content }; | ||
} | ||
|
||
private async getLogsFromFiles(logFiles: string[], logDir: string): Promise<TroubleshootingFileMap[]> { | ||
const logs: TroubleshootingFileMap[] = []; | ||
for (const file of logFiles) { | ||
try { | ||
const filePath = `${logDir}/${file}`; | ||
|
||
// Check if the file exists, if not, skip it. | ||
if (!fs.existsSync(filePath)) { | ||
continue; | ||
} | ||
|
||
const fileMap = await this.getFileSystemContent(filePath, file); | ||
logs.push(fileMap); | ||
} catch (error) { | ||
console.error(`Error reading ${file}: `, error); | ||
} | ||
} | ||
return logs; | ||
} | ||
generateLogFileName(filename: string, extension?: string): string { | ||
// If the filename has an extension like .log, move it to the end ofthe generated name | ||
// otherwise just use .txt | ||
const filenameParts = filename.split('.'); | ||
// Use the file extension if it's provided, otherwise use the one from the file name, or default to txt | ||
const fileExtension = extension ? extension : filenameParts.length > 1 ? filenameParts[1] : 'txt'; | ||
return `${filenameParts[0]}-${moment().format('YYYYMMDDHHmmss')}.${fileExtension}`; | ||
} | ||
} |
Oops, something went wrong.