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
### 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 #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 <charlie@charliedrage.com>
- Loading branch information
Showing
7 changed files
with
442 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,204 @@ | ||
/********************************************************************** | ||
* Copyright (C) 2023 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, generateLogFileName } from './troubleshooting.js'; | ||
import type { FileMap, LogType } from './troubleshooting.js'; | ||
import * as fs from 'node:fs'; | ||
|
||
const writeZipMock = vi.fn(); | ||
const addFileMock = 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(undefined); | ||
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(undefined); | ||
const fileMaps: FileMap[] = []; | ||
|
||
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(undefined); | ||
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(addFileMock).toHaveBeenCalledWith('file1.txt', Buffer.from('content1', 'utf8')); | ||
expect(addFileMock).toHaveBeenCalledWith('file2.txt', Buffer.from('content2', 'utf8')); | ||
}); | ||
|
||
// Expect getConsoleLogs to correctly format the console logs passed in | ||
test('Should correctly format console logs', async () => { | ||
const zipFile = new Troubleshooting(undefined); | ||
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(undefined); | ||
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.log'), | ||
'utf8', | ||
); | ||
expect(readFileMock).toHaveBeenCalledWith( | ||
expect.stringContaining('/Library/Logs/Podman Desktop/launchd-stderr.log'), | ||
'utf8', | ||
); | ||
}); | ||
|
||
// 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(undefined); | ||
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.log'), | ||
'utf8', | ||
); | ||
}); | ||
|
||
test('test generateLogFileName', async () => { | ||
const fileName = 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,118 @@ | ||
/********************************************************************** | ||
* Copyright (C) 2023 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'; | ||
|
||
const SYSTEM_FILENAME = 'system'; | ||
|
||
export interface FileMap { | ||
fileName: string; | ||
content: string; | ||
} | ||
|
||
export type LogType = 'log' | 'warn' | 'trace' | 'debug' | 'error'; | ||
|
||
export class Troubleshooting { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
constructor(private apiSender: any) {} | ||
|
||
// 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: FileMap[], 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); | ||
console.log(`Zip file saved to ${destination}`); | ||
} | ||
|
||
getConsoleLogs(consoleLogs: { logType: LogType; message: string }[]): FileMap[] { | ||
const content = consoleLogs.map(log => `${log.logType} : ${log.message}`).join('\n'); | ||
return [{ fileName: generateLogFileName('console'), content }]; | ||
} | ||
|
||
async getSystemLogs(): Promise<FileMap[]> { | ||
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: | ||
console.log('Unsupported platform for retrieving system logs'); | ||
return []; | ||
} | ||
} | ||
|
||
private async getFileSystemContent(filePath: string, logName: string): Promise<FileMap> { | ||
try { | ||
const content = await fs.promises.readFile(filePath, 'utf8'); | ||
return { fileName: generateLogFileName(SYSTEM_FILENAME + '-' + logName), content }; | ||
} catch (error) { | ||
console.error(`Error reading file from ${filePath}: `, error); | ||
throw error; | ||
} | ||
} | ||
|
||
private async getLogsFromFiles(logFiles: string[], logDir: string): Promise<FileMap[]> { | ||
const logs: FileMap[] = []; | ||
for (const file of logFiles) { | ||
try { | ||
const filePath = `${logDir}/${file}`; | ||
|
||
// Check if the file exists, if not, skip it. | ||
if (!fs.existsSync(filePath)) { | ||
console.log(`File ${filePath} does not exist, skipping`); | ||
continue; | ||
} | ||
|
||
const fileMap = await this.getFileSystemContent(filePath, file); | ||
logs.push(fileMap); | ||
} catch (error) { | ||
console.error(`Error reading ${file}: `, error); | ||
} | ||
} | ||
return logs; | ||
} | ||
} | ||
|
||
export function 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.