[MM-47202] - Desktop diagnostics (#2439)
* Add custom assertion to match object in array (#2358) * Add custom assertion to match object in array * Remove test that is not implemented yet * [MM-48213] Add "Run diagnostics" menu item under Help (#2359) * Add submenu item that runs diagnostics * Add custom assertion to match object in array * Remove test that is not implemented yet * Add tests * Add translation * [MM-47206] Diagnostics steps setup (#2361) * Add baseline code for diagnostics and their steps * Fix failing test * [MM-47206] [MM-48155] Obfuscate logs (#2369) * Add logging hooks to mask sensitive data * Add hook that truncates long strings in diagnostics logs * Add template file for creating steps * Add readme inside diagnostics * [MM-48145] Diagnostics step 2 - internet connectivity (#2372) * Add diagnostics step 2 - internet connectivity check * Update tests * [MM-48144] Diagnostics Step - Configure logger (#2390) * Configure logger * Move configure logger into step1 * Add file extension to fileName variable * Diagnostics Step 2: Validate configuration (#2391) * Resolve conflicts with base branch * Update test and implement Code review suggestion * Fix failing test * [MM-48147]Diagnostics step 3 - server connectivity (#2397) * Add step3: Check server connectivity by using the /api/v4/system/ping endpoint * Fix failing tests * Add better obfuscator functions that mask all types of data (#2399) * Add better obfuscator functions that mask all types of data(string, array, objects) * Update tests * [MM-48148] Add Diagnostics step 4 - session validation (#2398) * Add diagnostics step 4 - session data validation * Fix failing tests * [MM-48152] Add diagnostics step 5 - BrowserWindows checks (#2404) * Add diagnostics step 5 - browserwindow checks for main window * Add tests * [MM-48151] Diagnostics step 6 - Permissions (#2409) * Add diagnostics step 6 - Permissions check * Check permissions for microphone ond screen onn mac, windows * Update tests count in tests * [MM-48551] Diagnostics step 7 - Performance & Memory (#2410) * Add diagnostics step 6 - Permissions check * Check permissions for microphone ond screen onn mac, windows * Update tests count in tests * Add diagnostics step 7 - performance and memory * Fix failing tests * [MM-48153] Add diagnostics step 8 - Log heuristics (#2418) * Add diagnostics step 8 - Log heuristics * Add diagnostics step 9 - config (#2422) * [MM-48556] Diagnostics Step 10 - Crash reports (#2423) * Add diagnostics step 9 - config * Add diagnostics step 10 - include crash reports * Update tests * Add diagnostics step 11 - cookies report (#2427) * [MM-48157] Diagnostics report (#2432) * Add better logging and pretty print report * Update last step * Update log message * Move log after hooks so that path is masked * Use correct directory for diagnostics files
This commit is contained in:
parent
567b48516b
commit
161ae99e94
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
@ -24,7 +24,13 @@
|
|||
"ICONNAME",
|
||||
"inputflash",
|
||||
"loadscreen",
|
||||
"mailhost",
|
||||
"mailserver",
|
||||
"MMAUTHTOKEN",
|
||||
"MMCSRF",
|
||||
"mmjstool",
|
||||
"MMUSERID",
|
||||
"mochawesome",
|
||||
"NOSERVERS",
|
||||
"Ochiai",
|
||||
"officedocument",
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
"main.menus.app.help.downloadUpdate": "Download Update",
|
||||
"main.menus.app.help.learnMore": "Learn More...",
|
||||
"main.menus.app.help.restartAndUpdate": "Restart and Update",
|
||||
"main.menus.app.help.RunDiagnostics": "Run diagnostics",
|
||||
"main.menus.app.help.versionString": "Version {version}{commit}",
|
||||
"main.menus.app.history": "&History",
|
||||
"main.menus.app.history.back": "Back",
|
||||
|
|
|
@ -109,7 +109,10 @@
|
|||
"__SKIP_ONBOARDING_SCREENS__": false
|
||||
},
|
||||
"setupFiles": [
|
||||
"./src/jestSetup.js"
|
||||
"./src/jest/jestSetup.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./src/jest/jestSetupAfterEnv.js"
|
||||
],
|
||||
"reporters": [
|
||||
"default",
|
||||
|
|
|
@ -24,6 +24,7 @@ jest.mock('main/Validator', () => ({
|
|||
validateV1ConfigData: (configData) => (configData.version === 1 ? configData : null),
|
||||
validateV2ConfigData: (configData) => (configData.version === 2 ? configData : null),
|
||||
validateV3ConfigData: (configData) => (configData.version === 3 ? configData : null),
|
||||
validateConfigData: (configData) => (configData.version === 3 ? configData : null),
|
||||
}));
|
||||
|
||||
jest.mock('common/tabs/TabView', () => ({
|
||||
|
|
|
@ -357,19 +357,8 @@ export class Config extends EventEmitter {
|
|||
configData = this.readFileSync(this.configFilePath);
|
||||
|
||||
// validate based on config file version
|
||||
switch (configData.version) {
|
||||
case 3:
|
||||
configData = Validator.validateV3ConfigData(configData)!;
|
||||
break;
|
||||
case 2:
|
||||
configData = Validator.validateV2ConfigData(configData)!;
|
||||
break;
|
||||
case 1:
|
||||
configData = Validator.validateV1ConfigData(configData)!;
|
||||
break;
|
||||
default:
|
||||
configData = Validator.validateV0ConfigData(configData)!;
|
||||
}
|
||||
configData = Validator.validateConfigData(configData);
|
||||
|
||||
if (!configData) {
|
||||
throw new Error('Provided configuration file does not validate, using defaults instead.');
|
||||
}
|
||||
|
|
49
src/common/constants.test.js
Normal file
49
src/common/constants.test.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {REGEX_EMAIL} from './constants';
|
||||
|
||||
const VALID_EMAILS_LIST = [
|
||||
['email@example.com'],
|
||||
['firstname.lastname@example.com'],
|
||||
['disposable.style.email.with+symbol@example.com'],
|
||||
['other.email-with-hyphen@example.com'],
|
||||
['fully-qualified-domain@example.com'],
|
||||
['user.name+tag+sorting@example.com'],
|
||||
['x@example.com'],
|
||||
['example-indeed@strange-example.com'],
|
||||
['test/test@test.com'],
|
||||
['admin@mailserver1'],
|
||||
['example@s.example'],
|
||||
['john..doe@example.org'],
|
||||
['mailhost!username@example.org'],
|
||||
['user%example.com@example.org'],
|
||||
['user-@example.org'],
|
||||
['email@subdomain.example.com'],
|
||||
['firstname+lastname@example.com'],
|
||||
['email@123.123.123.123'],
|
||||
['1234567890@example.com'],
|
||||
['email@example-one.com'],
|
||||
['_______@example.com'],
|
||||
['email@example.name'],
|
||||
['email@example.museum'],
|
||||
['email@example.co.jp'],
|
||||
['firstname-lastname@example.com'],
|
||||
];
|
||||
|
||||
const INVALID_EMAILS_LIST = [
|
||||
['Abc.example.com'],
|
||||
['QA[icon]CHOCOLATE[icon]@test.com'],
|
||||
];
|
||||
|
||||
describe('main/common/constants', () => {
|
||||
describe('Email regular expression', () => {
|
||||
it.each(VALID_EMAILS_LIST)('%#:Should be VALID email address: %s', (a) => {
|
||||
expect(REGEX_EMAIL.test(a)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(INVALID_EMAILS_LIST)('%#: Should be INVALID email address: %s', (a) => {
|
||||
expect(REGEX_EMAIL.test(a)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -20,3 +20,29 @@ export const UPDATE_DOWNLOAD_ITEM: Omit<DownloadedItem, 'filename' | 'state'> =
|
|||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
|
||||
// Regular expressions
|
||||
export const REGEX_EMAIL = /[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*/; // based on W3C input type email regex
|
||||
export const REGEX_IPV4 = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
|
||||
export const REGEX_URL = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
|
||||
|
||||
export const REGEX_PATH_WIN32 = /(?:[a-z]:)?[/\\](?:[./\\ ](?![./\\\n])|[^<>:"|?*./\\ \n])+[a-zA-Z0-9]./;
|
||||
export const REGEX_PATH_DARWIN = /([/]{1}[a-z0-9.]+)+(\/?)|^([/])/;
|
||||
export const REGEX_PATH_LINUX = /([/]{1}[a-z0-9.]+)+(\/?)|^([/])/; // same as darwin
|
||||
|
||||
export const REGEX_LOG_FILE_LINE = /\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}.\d{3})\]\s\[(silly|debug|verbose|info|warn|error)\]\s+(.*)/;
|
||||
|
||||
// Masks
|
||||
export const MASK_EMAIL = 'EMAIL';
|
||||
export const MASK_IPV4 = 'IPV4';
|
||||
export const MASK_PATH = 'PATH';
|
||||
export const MASK_URL = 'URL';
|
||||
|
||||
export const LOGS_MAX_STRING_LENGTH = 63;
|
||||
|
||||
// We use this URL inside the Diagnostics to check if the computer has internet connectivity
|
||||
export const IS_ONLINE_ENDPOINT = 'https://community.mattermost.com/api/v4/system/ping';
|
||||
|
||||
export const COOKIE_NAME_USER_ID = 'MMUSERID';
|
||||
export const COOKIE_NAME_CSRF = 'MMCSRF';
|
||||
export const COOKIE_NAME_AUTH_TOKEN = 'MMAUTHTOKEN';
|
||||
|
|
|
@ -12,16 +12,25 @@ jest.mock('main/constants', () => ({
|
|||
updatePaths: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('electron-log', () => ({
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
silly: jest.fn(),
|
||||
transports: {
|
||||
file: {
|
||||
level: '',
|
||||
jest.mock('electron-log', () => {
|
||||
const logLevelsFn = {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
silly: jest.fn(),
|
||||
};
|
||||
return {
|
||||
create: jest.fn(() => ({
|
||||
...logLevelsFn,
|
||||
})),
|
||||
...logLevelsFn,
|
||||
transports: {
|
||||
file: {
|
||||
level: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
25
src/jest/jestSetupAfterEnv.js
Normal file
25
src/jest/jestSetupAfterEnv.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
function toContainObject(received, argument) {
|
||||
const pass = this.equals(received,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining(argument),
|
||||
]),
|
||||
);
|
||||
|
||||
if (pass) {
|
||||
return {
|
||||
message: () => (`expected ${this.utils.printReceived(received)} not to contain object ${this.utils.printExpected(argument)}`),
|
||||
pass: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: () => (`expected ${this.utils.printReceived(received)} to contain object ${this.utils.printExpected(argument)}`),
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toContainObject,
|
||||
});
|
|
@ -5,7 +5,7 @@ import log from 'electron-log';
|
|||
import Joi from 'joi';
|
||||
|
||||
import {Args} from 'types/args';
|
||||
import {ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config';
|
||||
import {AnyConfig, ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config';
|
||||
import {DownloadedItems} from 'types/downloads';
|
||||
import {SavedWindowState} from 'types/mainWindow';
|
||||
import {AppState} from 'types/appState';
|
||||
|
@ -260,6 +260,19 @@ export function validateV3ConfigData(data: ConfigV3) {
|
|||
return validateAgainstSchema(data, configDataSchemaV3);
|
||||
}
|
||||
|
||||
export function validateConfigData(data: AnyConfig) {
|
||||
switch (data.version) {
|
||||
case 3:
|
||||
return validateV3ConfigData(data)!;
|
||||
case 2:
|
||||
return validateV2ConfigData(data)!;
|
||||
case 1:
|
||||
return validateV1ConfigData(data)!;
|
||||
default:
|
||||
return validateV0ConfigData(data)!;
|
||||
}
|
||||
}
|
||||
|
||||
// validate certificate.json
|
||||
export function validateCertificateStore(data: string | Record<string, ComparableCertificate>) {
|
||||
const jsonData = (typeof data === 'object' ? data : JSON.parse(data));
|
||||
|
|
25
src/main/diagnostics/DiagnosticStep.ts
Normal file
25
src/main/diagnostics/DiagnosticStep.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DiagnosticsStepConstructorPayload} from 'types/diagnostics';
|
||||
|
||||
import {addDurationToFnReturnObject} from './steps/internal/utils';
|
||||
|
||||
const genericStepName = 'diagnostic-step/generic';
|
||||
|
||||
class DiagnosticsStep {
|
||||
name: DiagnosticsStepConstructorPayload['name'];
|
||||
retries: DiagnosticsStepConstructorPayload['retries'];
|
||||
run: DiagnosticsStepConstructorPayload['run'];
|
||||
|
||||
constructor({name = genericStepName, retries = 0, run}: DiagnosticsStepConstructorPayload) {
|
||||
if (typeof run !== 'function') {
|
||||
throw new Error(`"run" function is missing from step ${name}`);
|
||||
}
|
||||
this.name = name;
|
||||
this.retries = retries;
|
||||
this.run = addDurationToFnReturnObject(run);
|
||||
}
|
||||
}
|
||||
|
||||
export default DiagnosticsStep;
|
22
src/main/diagnostics/README.md
Normal file
22
src/main/diagnostics/README.md
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Desktop Diagnostics
|
||||
|
||||
This directory contains the code for running the diagnostics of the desktop application. (entrypoint `index.ts`)
|
||||
This readme file's purpose is to explain the code, the structure and how to contribute to it.
|
||||
|
||||
## How it works?
|
||||
|
||||
The class `DiagnosticsModule` in `index.ts` is the "orchestrator" that runs specific steps/tasks one at a time. It keeps track of whether or not a specific
|
||||
step has succeeded or not and stores the return values of the steps.
|
||||
|
||||
## Diagnostics Steps
|
||||
|
||||
The diagnostic steps at this moment are:
|
||||
|
||||
| Step | Name | Description |
|
||||
| :---: | :---: | :--- |
|
||||
| 0 | logger | Validates that the diagnostics write to the correct file and the log level is "debug" |
|
||||
|
||||
## Future enhancements
|
||||
|
||||
- Run steps in parallel (if necessary)
|
||||
- Background diagnostics monitoring
|
25
src/main/diagnostics/index.test.js
Normal file
25
src/main/diagnostics/index.test.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Diagnostics from '.';
|
||||
|
||||
jest.mock('main/windows/windowManager', () => ({
|
||||
mainWindow: {},
|
||||
}));
|
||||
jest.mock('common/config', () => ({
|
||||
configFilePath: 'mock/config/filepath/',
|
||||
}));
|
||||
|
||||
describe('main/diagnostics/index', () => {
|
||||
it('should be initialized with correct values', () => {
|
||||
const d = Diagnostics;
|
||||
expect(d.stepTotal).toBe(0);
|
||||
expect(d.stepCurrent).toBe(0);
|
||||
expect(d.report).toEqual([]);
|
||||
});
|
||||
|
||||
it('should count the steps correctly', () => {
|
||||
const d = Diagnostics;
|
||||
expect(d.getStepCount()).toBe(12);
|
||||
});
|
||||
});
|
186
src/main/diagnostics/index.ts
Normal file
186
src/main/diagnostics/index.ts
Normal file
|
@ -0,0 +1,186 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {shell} from 'electron';
|
||||
import log, {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticsReport} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from './DiagnosticStep';
|
||||
|
||||
import Step0 from './steps/step0.logLevel';
|
||||
import Step1 from './steps/step1.internetConnection';
|
||||
import Step10 from './steps/step10.crashReports';
|
||||
import Step11 from './steps/step11.auth';
|
||||
import Step2 from './steps/step2.configValidation';
|
||||
import Step3 from './steps/step3.serverConnectivity';
|
||||
import Step4 from './steps/step4.sessionDataValidation';
|
||||
import Step5 from './steps/step5.browserWindows';
|
||||
import Step6 from './steps/step6.permissions';
|
||||
import Step7 from './steps/step7.performance';
|
||||
import Step8 from './steps/step8.logHeuristics';
|
||||
import Step9 from './steps/step9.config';
|
||||
|
||||
const SORTED_STEPS: DiagnosticsStep[] = [
|
||||
Step0,
|
||||
Step1,
|
||||
Step2,
|
||||
Step3,
|
||||
Step4,
|
||||
Step5,
|
||||
Step6,
|
||||
Step7,
|
||||
Step8,
|
||||
Step9,
|
||||
Step10,
|
||||
Step11,
|
||||
];
|
||||
const maxStepNameLength = Math.max(...SORTED_STEPS.map((s) => s.name.length));
|
||||
const HASHTAGS = '#'.repeat(20);
|
||||
|
||||
class DiagnosticsModule {
|
||||
stepCurrent = 0;
|
||||
stepTotal = 0;
|
||||
report: DiagnosticsReport = [];
|
||||
logger: ElectronLog = log.create('diagnostics-logger');
|
||||
|
||||
run = async () => {
|
||||
try {
|
||||
if (this.isRunning()) {
|
||||
this.logger.warn('Diagnostics is already running');
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Diagnostics.run');
|
||||
|
||||
this.initializeValues();
|
||||
this.sendNotificationDiagnosticsStarted();
|
||||
await this.executeSteps();
|
||||
this.printReport();
|
||||
this.showLogFile();
|
||||
|
||||
this.initializeValues(true);
|
||||
} catch (error) {
|
||||
this.logger.error('Diagnostics.run Error: ', {error});
|
||||
this.initializeValues(true);
|
||||
}
|
||||
}
|
||||
|
||||
initializeValues = (clear = false) => {
|
||||
this.logger.transports.file.level = 'silly';
|
||||
this.logger.transports.console.level = 'silly';
|
||||
|
||||
this.logger.debug('Diagnostics.initializeValues');
|
||||
this.stepCurrent = 0;
|
||||
this.stepTotal = clear ? 0 : this.getStepCount();
|
||||
this.report = [];
|
||||
}
|
||||
|
||||
getStepCount = () => {
|
||||
const stepsCount = SORTED_STEPS.length;
|
||||
this.logger.debug('Diagnostics.getStepCount', {stepsCount});
|
||||
|
||||
return stepsCount;
|
||||
}
|
||||
|
||||
executeSteps = async () => {
|
||||
this.logger.info('Diagnostics.executeSteps Started');
|
||||
let index = 0;
|
||||
for (const step of SORTED_STEPS) {
|
||||
const reportStep = {
|
||||
name: step.name,
|
||||
step: index,
|
||||
};
|
||||
if (this.isValidStep(step)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const stepResult = await step.run(this.logger);
|
||||
this.printStepStart(step.name);
|
||||
this.addToReport({
|
||||
...stepResult,
|
||||
...reportStep,
|
||||
payload: stepResult.payload,
|
||||
});
|
||||
this.logger.info({stepResult});
|
||||
this.printStepEnd(step.name);
|
||||
} else {
|
||||
this.addToReport({
|
||||
...reportStep,
|
||||
succeeded: false,
|
||||
duration: 0,
|
||||
});
|
||||
this.logger.warn('Diagnostics.executeSteps UnknownStep', {index, step});
|
||||
}
|
||||
index++;
|
||||
this.stepCurrent = index;
|
||||
}
|
||||
this.logger.info('Diagnostics.executeSteps Finished');
|
||||
}
|
||||
|
||||
printReport = () => {
|
||||
const totalStepsCount = this.getStepCount();
|
||||
const successfulStepsCount = this.getSuccessfulStepsCount();
|
||||
this.printStepStart('Report');
|
||||
this.logger.info({report: this.report});
|
||||
this.logger.info(`| index | name${this.fillSpaces(maxStepNameLength - 4)} | succeeded |`);
|
||||
this.logger.info(`| ---${this.fillSpaces(3)}| ---${this.fillSpaces(maxStepNameLength - 3)} | ---${this.fillSpaces(7)}|`);
|
||||
|
||||
this.report.forEach((step, index) => {
|
||||
this.logger.info(`| ${index}${this.fillSpaces(6 - index.toString().length)}| ${step.name}${this.fillSpaces(maxStepNameLength - step.name.length)} | ${step.succeeded}${this.fillSpaces(step.succeeded ? 6 : 5)}|`);
|
||||
});
|
||||
|
||||
this.logger.info(`| ---${this.fillSpaces(3)}| ---${this.fillSpaces(maxStepNameLength - 3)} | ---${this.fillSpaces(7)}|`);
|
||||
this.logger.info(`${successfulStepsCount} out of ${totalStepsCount} steps succeeded`);
|
||||
this.printStepEnd('Report');
|
||||
}
|
||||
|
||||
showLogFile = () => {
|
||||
const pathToFile = this.getLoggerFilePath();
|
||||
|
||||
this.logger.debug('Diagnostics.showLogFile', {pathToFile});
|
||||
shell.showItemInFolder(pathToFile);
|
||||
}
|
||||
|
||||
sendNotificationDiagnosticsStarted = () => {
|
||||
this.logger.debug('Diagnostics sendNotification DiagnosticsStarted');
|
||||
}
|
||||
|
||||
isValidStep = (step: unknown) => {
|
||||
return step instanceof DiagnosticsStep;
|
||||
}
|
||||
|
||||
getLoggerFilePath = () => {
|
||||
return this.logger.transports.file.getFile()?.path;
|
||||
}
|
||||
|
||||
isRunning = () => {
|
||||
return this.stepTotal > 0 && this.stepCurrent >= 0;
|
||||
}
|
||||
|
||||
getSuccessfulStepsCount = () => {
|
||||
return this.report.filter((step) => step.succeeded).length;
|
||||
}
|
||||
|
||||
private printStepStart = (name: string) => {
|
||||
this.logger.info(`${HASHTAGS} ${name} START ${HASHTAGS}`);
|
||||
}
|
||||
|
||||
private printStepEnd = (name: string) => {
|
||||
this.logger.info(`${HASHTAGS} ${name} END ${HASHTAGS}`);
|
||||
}
|
||||
|
||||
private addToReport(data: DiagnosticsReport[number]): void {
|
||||
this.report = [
|
||||
...this.report,
|
||||
data,
|
||||
];
|
||||
}
|
||||
|
||||
private fillSpaces = (i: number) => {
|
||||
if (typeof i !== 'number' || i <= 0) {
|
||||
return '';
|
||||
}
|
||||
return ' '.repeat(i);
|
||||
}
|
||||
}
|
||||
|
||||
const Diagnostics = new DiagnosticsModule();
|
||||
|
||||
export default Diagnostics;
|
107
src/main/diagnostics/steps/internal/loggerHooks.test.js
Normal file
107
src/main/diagnostics/steps/internal/loggerHooks.test.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {MASK_EMAIL, MASK_PATH} from 'common/constants';
|
||||
|
||||
import {maskMessageDataHook} from './loggerHooks';
|
||||
|
||||
const loggerMock = {
|
||||
transports: {
|
||||
file: 'file',
|
||||
},
|
||||
};
|
||||
|
||||
function findOccurrencesInString(search, text) {
|
||||
const regex = new RegExp(search, 'g');
|
||||
return (text.match(regex))?.length || 0;
|
||||
}
|
||||
|
||||
describe('main/diagnostics/loggerHooks', () => {
|
||||
it('should mask nothing when it prints only to console', () => {
|
||||
const message = {
|
||||
data: ['password email@test.com https://192.168.1.1'],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'console').data[0];
|
||||
expect(result).toBe(message.data[0]);
|
||||
});
|
||||
|
||||
it('should return empty "" if the message includes the string "password"', () => {
|
||||
const message = {
|
||||
data: ['Password: someRandomPassword'],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file');
|
||||
expect(result.data[0]).toBe('');
|
||||
});
|
||||
|
||||
it('should mask emails', () => {
|
||||
const message = {
|
||||
data: ['email@test.com email2@test.com'],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
|
||||
expect(findOccurrencesInString(MASK_EMAIL, result)).toBe(2);
|
||||
});
|
||||
|
||||
it('should mask IPV4 addresses', () => {
|
||||
const IPs = ['192.168.20.44', '1.1.1.1', '255.255.255.255'];
|
||||
const message = {
|
||||
data: [`:${IPs[0]} https://${IPs[1]} networkPc://${IPs[2]}`],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
|
||||
expect(IPs.some((ip) => result.includes(ip))).toBe(false);
|
||||
});
|
||||
|
||||
it('should mask URLs', () => {
|
||||
const URLs = ['http://www.google.com', 'https://community.mattermost.com', 'https://someWebsite.com'];
|
||||
const message = {
|
||||
data: [`${URLs[0]} https://${URLs[1]} http://${URLs[2]}`],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
|
||||
expect(URLs.some((url) => result.includes(url))).toBe(false);
|
||||
});
|
||||
|
||||
describe('should mask paths for all OSs', () => {
|
||||
it('darwin', () => {
|
||||
const message = {
|
||||
data: ['/Users/user/Projects/desktop /Users/user/Projects/desktop/file.txt /Users/user/Projects/desktop/folder withSpace/file.txt'],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
|
||||
expect(findOccurrencesInString(MASK_PATH, result)).toBe(4);
|
||||
});
|
||||
it('linux', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
const message = {
|
||||
data: ['/Users/user/Projects/desktop /Users/user/Projects/desktop/file.txt /Users/user/Projects/desktop/folder withSpace/file.txt'],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
|
||||
expect(findOccurrencesInString(MASK_PATH, result)).toBe(4);
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
it('windows', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
});
|
||||
const message = {
|
||||
data: ['C:/Users/user/Desktop/download.pdf C:/Users/user/Desktop/folder withSpace/file.txt'],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
|
||||
expect(findOccurrencesInString(MASK_PATH, result)).toBe(3);
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should truncate very longs substrings', () => {
|
||||
const message = {
|
||||
data: ['ThisIsAVeryVeryVeryVeryVeryVeryVeryVeryLongStringProbablyAToken'],
|
||||
};
|
||||
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
|
||||
expect(result).toBe('This...en');
|
||||
});
|
||||
});
|
28
src/main/diagnostics/steps/internal/loggerHooks.ts
Normal file
28
src/main/diagnostics/steps/internal/loggerHooks.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElectronLog} from 'electron-log';
|
||||
|
||||
import {obfuscateByType} from './obfuscators';
|
||||
|
||||
type ElectronLogHook = ElectronLog['hooks'][number];
|
||||
type ElectronLogHookCreator = (l: ElectronLog) => ElectronLogHook;
|
||||
|
||||
export const maskMessageDataHook: ElectronLogHookCreator = (logger) => (message, transport) => {
|
||||
if (transport !== logger.transports.file) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// iterate the arguments of the log command, eg log.debug(a, b, c, ...);
|
||||
for (let i = 0; i < message.data.length; i++) {
|
||||
message.data[i] = obfuscateByType(message.data[i]);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const loggerHooks: (logger: ElectronLog) => ElectronLog['hooks'] = (logger) => [
|
||||
maskMessageDataHook(logger),
|
||||
];
|
||||
|
||||
export default loggerHooks;
|
83
src/main/diagnostics/steps/internal/obfuscators.ts
Normal file
83
src/main/diagnostics/steps/internal/obfuscators.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {MASK_EMAIL, MASK_IPV4, MASK_PATH, MASK_URL, REGEX_EMAIL, REGEX_IPV4, REGEX_PATH_DARWIN, REGEX_PATH_LINUX, REGEX_PATH_WIN32, REGEX_URL} from 'common/constants';
|
||||
|
||||
import {truncateString} from './utils';
|
||||
|
||||
const isDarwin = process.platform === 'darwin';
|
||||
const isLinux = process.platform === 'linux';
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
function maskDataInString(str: string): string {
|
||||
let maskedStr = str;
|
||||
if (!str || typeof str !== 'string') {
|
||||
return str;
|
||||
}
|
||||
|
||||
// Specific keywords
|
||||
if (str?.toLowerCase?.().includes('password')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Emails
|
||||
if (REGEX_EMAIL.test(str)) {
|
||||
maskedStr = maskedStr.replaceAll(RegExp(REGEX_EMAIL, 'gi'), MASK_EMAIL);
|
||||
}
|
||||
|
||||
// IP addresses
|
||||
if (REGEX_IPV4.test(str)) {
|
||||
maskedStr = maskedStr.replaceAll(RegExp(REGEX_IPV4, 'gi'), MASK_IPV4);
|
||||
}
|
||||
|
||||
// URLs
|
||||
if (REGEX_URL.test(str)) {
|
||||
maskedStr = maskedStr.replaceAll(RegExp(REGEX_URL, 'gi'), MASK_URL);
|
||||
}
|
||||
|
||||
// Paths
|
||||
if (isDarwin) {
|
||||
if (REGEX_PATH_DARWIN.test(str)) {
|
||||
maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_DARWIN, 'gi'), MASK_PATH);
|
||||
}
|
||||
} else if (isLinux) {
|
||||
if (REGEX_PATH_LINUX.test(str)) {
|
||||
maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_LINUX, 'gi'), MASK_PATH);
|
||||
}
|
||||
} else if (isWin) {
|
||||
if (REGEX_PATH_WIN32.test(str)) {
|
||||
maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_WIN32, 'gi'), MASK_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
// Very long strings will be truncated (eg tokens)
|
||||
maskedStr = maskedStr.split(/,| |\r?\n/)?.map?.((str: string) => truncateString(str))?.join?.(' ');
|
||||
|
||||
return maskedStr;
|
||||
}
|
||||
|
||||
function maskDataInArray(arr: unknown[]): unknown[] {
|
||||
return arr.map((el) => {
|
||||
return obfuscateByType(el);
|
||||
});
|
||||
}
|
||||
|
||||
function maskDataInObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.keys(obj).reduce<Record<string, unknown>>((acc, key) => {
|
||||
acc[key] = obfuscateByType(obj[key]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function obfuscateByType(item: unknown): unknown {
|
||||
const elType = typeof item;
|
||||
if (elType === 'string') {
|
||||
return maskDataInString(item as string);
|
||||
} else if (elType === 'object') {
|
||||
if (Array.isArray(item)) {
|
||||
return maskDataInArray(item);
|
||||
}
|
||||
return maskDataInObject(item as Record<string, unknown>);
|
||||
}
|
||||
return item;
|
||||
}
|
186
src/main/diagnostics/steps/internal/utils.test.js
Normal file
186
src/main/diagnostics/steps/internal/utils.test.js
Normal file
|
@ -0,0 +1,186 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import fs from 'fs';
|
||||
|
||||
import config from 'common/config';
|
||||
|
||||
import {addDurationToFnReturnObject, boundsOk, browserWindowVisibilityStatus, checkPathPermissions, truncateString, webContentsCheck} from './utils';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
});
|
||||
|
||||
const timeToSleep = 100;
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
access: jest.fn(),
|
||||
},
|
||||
constants: {
|
||||
W_OK: 0,
|
||||
},
|
||||
}));
|
||||
jest.mock('common/config', () => ({
|
||||
downloadLocation: 'path/to/file.txt',
|
||||
}));
|
||||
describe('main/diagnostics/utils', () => {
|
||||
describe('addDurationToFnReturnObject', () => {
|
||||
it('should measure the execution time of a function and include it in the response', async () => {
|
||||
const functionToMeasure = async () => {
|
||||
await sleep(timeToSleep);
|
||||
};
|
||||
const fn = addDurationToFnReturnObject(functionToMeasure);
|
||||
const b = await fn();
|
||||
expect(b.duration).toBeGreaterThan(timeToSleep - 10);
|
||||
expect(b.duration).toBeLessThan(timeToSleep * 1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateString', () => {
|
||||
it('should truncate very long string', () => {
|
||||
const str = 'ThisIsAVeryVeryVeryVeryVeryVeryVeryVeryLongStringProbablyAToken';
|
||||
expect(truncateString(str)).toBe('This...en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundsOk', () => {
|
||||
const bounds = {
|
||||
x: 17,
|
||||
y: 42,
|
||||
width: 800,
|
||||
height: 600,
|
||||
};
|
||||
const zeroBounds = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
/** TRUE */
|
||||
it('should return true if the bounds Rectangle is valid - not strict', () => {
|
||||
expect(boundsOk(bounds)).toBe(true);
|
||||
});
|
||||
it('should return true if the bounds Rectangle is valid - strict', () => {
|
||||
expect(boundsOk(bounds, true)).toBe(true);
|
||||
});
|
||||
it('should return true if the bounds Rectangle is valid - not strict', () => {
|
||||
expect(boundsOk(zeroBounds)).toBe(true);
|
||||
});
|
||||
|
||||
/** FALSE */
|
||||
it('should return false if the bounds Rectangle is invalid - strict', () => {
|
||||
expect(boundsOk(zeroBounds, true)).toBe(false);
|
||||
});
|
||||
it('should return false if the bounds Rectangle is invalid - not strict', () => {
|
||||
expect(boundsOk({x: 0, y: 0})).toBe(false);
|
||||
});
|
||||
it('should return false if the bounds Rectangle is invalid - not strict', () => {
|
||||
expect(boundsOk({x: 0, y: 0, width: 0})).toBe(false);
|
||||
});
|
||||
it('should return false if the bounds Rectangle is invalid - not strict', () => {
|
||||
expect(boundsOk('a_string')).toBe(false);
|
||||
});
|
||||
it('should return false if the bounds Rectangle is invalid - not strict', () => {
|
||||
expect(boundsOk(42)).toBe(false);
|
||||
});
|
||||
it('should return false if the bounds Rectangle is invalid - not strict', () => {
|
||||
expect(boundsOk({...bounds, x: '10'})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browserWindowVisibilityStatus', () => {
|
||||
const bWindow = {
|
||||
getBounds: () => ({x: 0, y: 0, width: 800, height: 600}),
|
||||
getOpacity: () => 1,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isEnabled: () => true,
|
||||
getBrowserViews: () => [{
|
||||
getBounds: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 500,
|
||||
}),
|
||||
}],
|
||||
};
|
||||
it('should return true if window ok', () => {
|
||||
expect(browserWindowVisibilityStatus('testWindow', bWindow).every((check) => check.ok)).toBe(true);
|
||||
});
|
||||
it('should return false if bounds not ok', () => {
|
||||
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, getBounds: () => ({x: -1, y: -1, width: 200, height: 100})}).every((check) => check.ok)).toBe(false);
|
||||
});
|
||||
it('should return false if opacity is 0', () => {
|
||||
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, getOpacity: () => 0.0}).every((check) => check.ok)).toBe(false);
|
||||
});
|
||||
it('should return false if window is destroyed', () => {
|
||||
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isDestroyed: () => true}).every((check) => check.ok)).toBe(false);
|
||||
});
|
||||
it('should return false if window is not visible', () => {
|
||||
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isVisible: () => false}).every((check) => check.ok)).toBe(false);
|
||||
});
|
||||
it('should return false if window is not enabled', () => {
|
||||
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isEnabled: () => false}).every((check) => check.ok)).toBe(false);
|
||||
});
|
||||
it('should return false if a child browserView has invalid bounds', () => {
|
||||
expect(browserWindowVisibilityStatus('testWindow', {
|
||||
...bWindow,
|
||||
getBrowserViews: () => [{
|
||||
getBounds: () => ({
|
||||
x: -1,
|
||||
y: -4000,
|
||||
width: 800,
|
||||
height: 500,
|
||||
}),
|
||||
}],
|
||||
}).every((check) => check.ok)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webContentsCheck', () => {
|
||||
it('should return true if webContents are ok', () => {
|
||||
expect(webContentsCheck({
|
||||
isCrashed: () => false,
|
||||
isDestroyed: () => false,
|
||||
isWaitingForResponse: () => false,
|
||||
})).toBe(true);
|
||||
});
|
||||
it('should return false if webcontents is undefined', () => {
|
||||
expect(webContentsCheck()).toBe(false);
|
||||
});
|
||||
it('should return false if webContents has crashed', () => {
|
||||
expect(webContentsCheck({
|
||||
isCrashed: () => true,
|
||||
isDestroyed: () => false,
|
||||
isWaitingForResponse: () => false,
|
||||
})).toBe(false);
|
||||
});
|
||||
it('should return false if webContents is destroyed', () => {
|
||||
expect(webContentsCheck({
|
||||
isCrashed: () => false,
|
||||
isDestroyed: () => true,
|
||||
isWaitingForResponse: () => false,
|
||||
})).toBe(false);
|
||||
});
|
||||
it('should return false if webContents is waiting for response', () => {
|
||||
expect(webContentsCheck({
|
||||
isCrashed: () => false,
|
||||
isDestroyed: () => false,
|
||||
isWaitingForResponse: () => true,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPathPermissions', () => {
|
||||
it('should return {ok: true}', async () => {
|
||||
fs.promises.access.mockImplementation(() => Promise.resolve(undefined));
|
||||
expect(await checkPathPermissions(config.downloadLocation, fs.constants.W_OK)).toStrictEqual({ok: true});
|
||||
});
|
||||
it('should return {ok: false}', async () => {
|
||||
const error = new Error('some error');
|
||||
fs.promises.access.mockImplementation(() => Promise.reject(error));
|
||||
expect(await checkPathPermissions(config.downloadLocation, fs.constants.W_OK)).toStrictEqual({ok: false, error});
|
||||
});
|
||||
});
|
||||
});
|
242
src/main/diagnostics/steps/internal/utils.ts
Normal file
242
src/main/diagnostics/steps/internal/utils.ts
Normal file
|
@ -0,0 +1,242 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import readline from 'readline';
|
||||
|
||||
import {BrowserWindow, Rectangle, WebContents} from 'electron';
|
||||
import log, {ElectronLog, LogLevel} from 'electron-log';
|
||||
import {AddDurationToFnReturnObject, LogFileLineData, LogLevelAmounts, WindowStatus} from 'types/diagnostics';
|
||||
|
||||
import {IS_ONLINE_ENDPOINT, LOGS_MAX_STRING_LENGTH, REGEX_LOG_FILE_LINE} from 'common/constants';
|
||||
|
||||
export function dateTimeInFilename(date?: Date) {
|
||||
const now = date ?? new Date();
|
||||
return `${now.getDate()}-${now.getMonth()}-${now.getFullYear()}_${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}-${now.getMilliseconds()}`;
|
||||
}
|
||||
|
||||
export function boundsOk(bounds?: Rectangle, strict = false): boolean {
|
||||
if (!bounds) {
|
||||
return false;
|
||||
}
|
||||
if (typeof bounds !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propertiesOk = ['x', 'y', 'width', 'height'].every((key) => Object.prototype.hasOwnProperty.call(bounds, key));
|
||||
const valueTypesOk = Object.values(bounds).every((value) => typeof value === 'number');
|
||||
|
||||
if (!propertiesOk || !valueTypesOk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strict) {
|
||||
return bounds.height > 0 && bounds.width > 0 && bounds.x >= 0 && bounds.y >= 0;
|
||||
}
|
||||
|
||||
return bounds.height >= 0 && bounds.width >= 0 && bounds.x >= 0 && bounds.y >= 0;
|
||||
}
|
||||
|
||||
export const addDurationToFnReturnObject: AddDurationToFnReturnObject = (run) => {
|
||||
return async (logger) => {
|
||||
const startTime = Date.now();
|
||||
const runReturnValues = await run(logger);
|
||||
return {
|
||||
...runReturnValues,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function truncateString(str: string, maxLength = LOGS_MAX_STRING_LENGTH): string {
|
||||
if (typeof str === 'string') {
|
||||
const length = str.length;
|
||||
if (length >= maxLength) {
|
||||
return `${str.substring(0, 4)}...${str.substring(length - 2, length)}`;
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export async function isOnline(logger: ElectronLog = log, url = IS_ONLINE_ENDPOINT): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
https.get(url, (resp) => {
|
||||
let data = '';
|
||||
|
||||
// A chunk of data has been received.
|
||||
resp.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
// The whole response has been received. Print out the result.
|
||||
resp.on('end', () => {
|
||||
logger.debug('resp.on.end', {data});
|
||||
const respBody = JSON.parse(data);
|
||||
if (respBody.status === 'OK') {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
logger.error('diagnostics isOnline Error', {err});
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function browserWindowVisibilityStatus(name: string, bWindow?: BrowserWindow): WindowStatus {
|
||||
const status: WindowStatus = [];
|
||||
|
||||
if (!bWindow) {
|
||||
status.push({
|
||||
name: 'windowExists',
|
||||
ok: false,
|
||||
});
|
||||
return status;
|
||||
}
|
||||
|
||||
const bounds = bWindow.getBounds();
|
||||
const opacity = bWindow.getOpacity();
|
||||
const destroyed = bWindow.isDestroyed();
|
||||
const visible = bWindow.isVisible();
|
||||
const enabled = bWindow.isEnabled();
|
||||
const browserViewsBounds = bWindow.getBrowserViews()?.map((view) => view.getBounds());
|
||||
|
||||
status.push({
|
||||
name: 'windowExists',
|
||||
ok: true,
|
||||
});
|
||||
|
||||
status.push({
|
||||
name: 'bounds',
|
||||
ok: boundsOk(bounds, true),
|
||||
data: bounds,
|
||||
});
|
||||
|
||||
status.push({
|
||||
name: 'opacity',
|
||||
ok: opacity > 0 && opacity <= 1,
|
||||
data: opacity,
|
||||
});
|
||||
|
||||
status.push({
|
||||
name: 'destroyed',
|
||||
ok: !destroyed,
|
||||
});
|
||||
status.push({
|
||||
name: 'visible',
|
||||
ok: visible,
|
||||
});
|
||||
status.push({
|
||||
name: 'enabled',
|
||||
ok: enabled,
|
||||
});
|
||||
status.push({
|
||||
name: 'browserViewsBounds',
|
||||
ok: browserViewsBounds.every((bounds) => boundsOk(bounds)),
|
||||
data: browserViewsBounds,
|
||||
});
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export function webContentsCheck(webContents?: WebContents) {
|
||||
if (!webContents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !webContents.isCrashed() && !webContents.isDestroyed() && !webContents.isWaitingForResponse();
|
||||
}
|
||||
|
||||
export async function checkPathPermissions(path?: fs.PathLike, mode?: number) {
|
||||
try {
|
||||
if (!path) {
|
||||
throw new Error('Invalid path');
|
||||
}
|
||||
await fs.promises.access(path, mode);
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseLogFileLine(line: string, lineMatchPattern: RegExp): LogFileLineData {
|
||||
const data = line.match(lineMatchPattern);
|
||||
return {
|
||||
text: line,
|
||||
date: data?.[1],
|
||||
logLevel: data?.[2] as LogLevel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The current setup of `electron-log` rotates the file when it reaches ~1mb. It's safe to assume that the file will not be large enough to cause
|
||||
* issues reading it in the same process. If this read function ever causes performance issues we should either execute it in a child process or
|
||||
* read up to X amount of lines (eg 10.000)
|
||||
*/
|
||||
export async function readFileLineByLine(path: fs.PathLike, lineMatchPattern = REGEX_LOG_FILE_LINE): Promise<{lines: LogFileLineData[]; logLevelAmounts: LogLevelAmounts}> {
|
||||
const logLevelAmounts = {
|
||||
silly: 0,
|
||||
debug: 0,
|
||||
verbose: 0,
|
||||
info: 0,
|
||||
warn: 0,
|
||||
error: 0,
|
||||
};
|
||||
const lines: LogFileLineData[] = [];
|
||||
|
||||
if (!path) {
|
||||
return {
|
||||
lines,
|
||||
logLevelAmounts,
|
||||
};
|
||||
}
|
||||
|
||||
const fileStream = fs.createReadStream(path);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
|
||||
/**
|
||||
* Note: we use the crlfDelay option to recognize all instances of CR LF
|
||||
* ('\r\n') in input.txt as a single line break.
|
||||
*/
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let i = -1;
|
||||
|
||||
for await (const line of rl) {
|
||||
const isValidLine = new RegExp(lineMatchPattern, 'gi').test(line);
|
||||
|
||||
if (isValidLine || i === -1) {
|
||||
i++;
|
||||
const lineData = parseLogFileLine(line, lineMatchPattern);
|
||||
|
||||
if (lineData.logLevel) {
|
||||
logLevelAmounts[lineData.logLevel]++;
|
||||
}
|
||||
|
||||
//push in array as new line
|
||||
lines.push(lineData);
|
||||
} else {
|
||||
//concat with previous line
|
||||
lines[i].text = `${lines[i].text}${line}`;
|
||||
}
|
||||
|
||||
// exit loop in edge case of very large file or infinite loop
|
||||
if (i >= 100000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
logLevelAmounts,
|
||||
};
|
||||
}
|
37
src/main/diagnostics/steps/step.template.ts
Normal file
37
src/main/diagnostics/steps/step.template.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
const stepName = 'Step-X';
|
||||
const stepDescriptiveName = 'Template';
|
||||
|
||||
// COPY & PASTE this file to create a new step
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
await Promise.resolve();
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const StepTemplate = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default StepTemplate;
|
51
src/main/diagnostics/steps/step0.logLevel.ts
Normal file
51
src/main/diagnostics/steps/step0.logLevel.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import path from 'path';
|
||||
|
||||
import {app} from 'electron';
|
||||
import {ElectronLog} from 'electron-log';
|
||||
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
import loggerHooks from './internal/loggerHooks';
|
||||
import {dateTimeInFilename} from './internal/utils';
|
||||
|
||||
const stepName = 'Step-0';
|
||||
const stepDescriptiveName = 'logConfig';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const filename = `diagnostics_${dateTimeInFilename()}.txt`;
|
||||
const pathToFile = path.join(app.getPath('userData'), `diagnostics/${filename}`);
|
||||
logger.transports.file.resolvePath = () => pathToFile;
|
||||
logger.transports.file.fileName = filename;
|
||||
|
||||
logger.hooks.push(...loggerHooks(logger));
|
||||
logger.transports.file.level = 'silly';
|
||||
logger.transports.console.level = 'silly';
|
||||
|
||||
logger.debug('ConfigureLogger', {filename, pathToFile});
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step0 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step0;
|
43
src/main/diagnostics/steps/step1.internetConnection.ts
Normal file
43
src/main/diagnostics/steps/step1.internetConnection.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
import {isOnline} from './internal/utils';
|
||||
|
||||
const stepName = 'Step-1';
|
||||
const stepDescriptiveName = 'internetConnection';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const success = await isOnline(logger);
|
||||
if (success) {
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step1 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step1;
|
54
src/main/diagnostics/steps/step10.crashReports.ts
Normal file
54
src/main/diagnostics/steps/step10.crashReports.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {app} from 'electron';
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
const stepName = 'Step-10';
|
||||
const stepDescriptiveName = 'CrashReports';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const pathOfCrashReports = app.getPath('userData');
|
||||
const allDirFiles = await fs.promises.readdir(pathOfCrashReports);
|
||||
const crashReportFiles = allDirFiles.filter((fileName) => fileName.startsWith('uncaughtException-'));
|
||||
|
||||
const crashReportData = await Promise.all(crashReportFiles.map(async (fileName) => {
|
||||
return {
|
||||
data: await fs.promises.readFile(path.join(pathOfCrashReports, fileName), {encoding: 'utf-8'}),
|
||||
fileName,
|
||||
};
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
pathOfCrashReports,
|
||||
crashReportData,
|
||||
};
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step10 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step10;
|
50
src/main/diagnostics/steps/step11.auth.ts
Normal file
50
src/main/diagnostics/steps/step11.auth.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {session} from 'electron';
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
const stepName = 'Step-11';
|
||||
const stepDescriptiveName = 'AuthSSO';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const cookies = await session.defaultSession.cookies.get({});
|
||||
|
||||
if (!cookies) {
|
||||
throw new Error('No cookies found');
|
||||
}
|
||||
|
||||
const payload = cookies.map((cookie) => {
|
||||
return {
|
||||
name: cookie?.name,
|
||||
expirationDate: cookie?.expirationDate,
|
||||
session: cookie?.session,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step11 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step11;
|
48
src/main/diagnostics/steps/step2.configValidation.ts
Normal file
48
src/main/diagnostics/steps/step2.configValidation.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import fs from 'fs';
|
||||
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import Config from 'common/config';
|
||||
import * as Validator from 'main/Validator';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
const stepName = 'Step-2';
|
||||
const stepDescriptiveName = 'configValidation';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const configData = JSON.parse(fs.readFileSync(Config.configFilePath, 'utf8'));
|
||||
|
||||
// validate based on config file version
|
||||
const validData = Validator.validateConfigData(configData);
|
||||
|
||||
if (!validData) {
|
||||
throw new Error(`Config validation failed. Config: ${JSON.stringify(Config.combinedData, null, 4)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step2 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step2;
|
56
src/main/diagnostics/steps/step3.serverConnectivity.ts
Normal file
56
src/main/diagnostics/steps/step3.serverConnectivity.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import Config from 'common/config';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
import {isOnline} from './internal/utils';
|
||||
|
||||
const stepName = 'Step-3';
|
||||
const stepDescriptiveName = 'serverConnectivity';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const teams = Config.combinedData?.teams || [];
|
||||
|
||||
await Promise.all(teams.map(async (team) => {
|
||||
logger.debug('Pinging server: ', team.url);
|
||||
|
||||
if (!team.name || !team.url) {
|
||||
throw new Error(`Invalid server configuration. Team Url: ${team.url}, team name: ${team.name}`);
|
||||
}
|
||||
|
||||
const serverOnline = await isOnline(logger, `${team.url}/api/v4/system/ping`);
|
||||
|
||||
if (!serverOnline) {
|
||||
throw new Error(`Server appears to be offline. Team url: ${team.url}`);
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload: teams,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step3 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step3;
|
54
src/main/diagnostics/steps/step4.sessionDataValidation.ts
Normal file
54
src/main/diagnostics/steps/step4.sessionDataValidation.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {session} from 'electron';
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import {COOKIE_NAME_AUTH_TOKEN, COOKIE_NAME_CSRF, COOKIE_NAME_USER_ID} from 'common/constants';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
const stepName = 'Step-4';
|
||||
const stepDescriptiveName = 'sessionDataValidation';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const cookies = await session.defaultSession.cookies.get({});
|
||||
if (!cookies) {
|
||||
logger.error(`${stepName}: No cookies found`);
|
||||
throw new Error('No cookies found');
|
||||
}
|
||||
|
||||
const userId = cookies.find((cookie) => cookie.name === COOKIE_NAME_USER_ID);
|
||||
const csrf = cookies.find((cookie) => cookie.name === COOKIE_NAME_CSRF);
|
||||
const authToken = cookies.find((cookie) => cookie.name === COOKIE_NAME_AUTH_TOKEN);
|
||||
|
||||
if (!userId || !csrf || !authToken) {
|
||||
const errMessage = `Not all required cookies found. "userId": ${Boolean(userId)}, "csrf": ${Boolean(csrf)}, "authToken": ${Boolean(authToken)}`;
|
||||
logger.error(`${stepName}: ${errMessage}`);
|
||||
throw new Error(errMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step4 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step4;
|
64
src/main/diagnostics/steps/step5.browserWindows.ts
Normal file
64
src/main/diagnostics/steps/step5.browserWindows.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElectronLog} from 'electron-log';
|
||||
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import windowManager from 'main/windows/windowManager';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
import {browserWindowVisibilityStatus, webContentsCheck} from './internal/utils';
|
||||
|
||||
const stepName = 'Step-5';
|
||||
const stepDescriptiveName = 'BrowserWindowsChecks';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
/** Main window check */
|
||||
if (!windowManager.mainWindowReady) {
|
||||
throw new Error('Main window not ready');
|
||||
}
|
||||
const mainWindowVisibilityStatus = browserWindowVisibilityStatus('mainWindow', windowManager.mainWindow);
|
||||
const webContentsOk = webContentsCheck(windowManager.mainWindow?.webContents);
|
||||
|
||||
if (mainWindowVisibilityStatus.some((status) => !status.ok) || !webContentsOk) {
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: {
|
||||
message: 'Some checks failed for main window',
|
||||
data: {
|
||||
mainWindowVisibilityStatus,
|
||||
webContentsOk,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload: {
|
||||
mainWindowVisibilityStatus,
|
||||
webContentsOk,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step5 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step5;
|
64
src/main/diagnostics/steps/step6.permissions.ts
Normal file
64
src/main/diagnostics/steps/step6.permissions.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import fs from 'fs';
|
||||
|
||||
import {Notification, systemPreferences} from 'electron';
|
||||
import log, {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
import config from 'common/config';
|
||||
|
||||
import {checkPathPermissions} from './internal/utils';
|
||||
|
||||
const stepName = 'Step-6';
|
||||
const stepDescriptiveName = 'PermissionsCheck';
|
||||
|
||||
const isDarwin = process.platform === 'darwin';
|
||||
const isWin32 = process.platform === 'win32';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const downloadsFileAccess = await checkPathPermissions(config.downloadLocation, fs.constants.W_OK);
|
||||
const logsFileAccess = await checkPathPermissions(log.transports.file.getFile().path, fs.constants.W_OK);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
notificationsSupported: Notification.isSupported(),
|
||||
fileSystem: {
|
||||
downloadsFileAccess,
|
||||
logsFileAccess,
|
||||
},
|
||||
};
|
||||
|
||||
if (isDarwin || isWin32) {
|
||||
if (isDarwin) {
|
||||
payload.isTrustedAccessibilityClient = systemPreferences.isTrustedAccessibilityClient(false);
|
||||
}
|
||||
payload.mediaAccessStatus = {
|
||||
mic: systemPreferences.getMediaAccessStatus('microphone'),
|
||||
screen: systemPreferences.getMediaAccessStatus('screen'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step6 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step6;
|
68
src/main/diagnostics/steps/step7.performance.ts
Normal file
68
src/main/diagnostics/steps/step7.performance.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import path from 'path';
|
||||
|
||||
import {app, powerMonitor} from 'electron';
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
import {dateTimeInFilename} from './internal/utils';
|
||||
|
||||
const stepName = 'Step-7';
|
||||
const stepDescriptiveName = 'PerformanceAndMemory';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const heapSnapshotFilepath = path.join(app.getAppPath(), `heapSnapshots/heap_snap_${dateTimeInFilename()}.txt`);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
process: {
|
||||
creationTime: process.getCreationTime(),
|
||||
heapStatistics: process.getHeapStatistics(),
|
||||
blinkMemory: process.getBlinkMemoryInfo(),
|
||||
processMemory: process.getProcessMemoryInfo(),
|
||||
systemMemory: process.getSystemMemoryInfo(),
|
||||
systemVersion: process.getSystemVersion(),
|
||||
cpuUsage: process.getCPUUsage(),
|
||||
heapSnapshot: {
|
||||
path: heapSnapshotFilepath,
|
||||
success: process.takeHeapSnapshot(heapSnapshotFilepath),
|
||||
},
|
||||
IOCounters: process.getIOCounters(),
|
||||
uptime: process.uptime(),
|
||||
platform: process.platform,
|
||||
sandboxed: process.sandboxed,
|
||||
contextIsolated: process.contextIsolated,
|
||||
type: process.type,
|
||||
versions: process.versions,
|
||||
version: process.version,
|
||||
mas: process.mas,
|
||||
windowsStore: process.windowsStore,
|
||||
},
|
||||
onBattery: powerMonitor.onBatteryPower,
|
||||
};
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step7 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step7;
|
56
src/main/diagnostics/steps/step8.logHeuristics.ts
Normal file
56
src/main/diagnostics/steps/step8.logHeuristics.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import log, {ElectronLog} from 'electron-log';
|
||||
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import {getPercentage} from 'main/utils';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
import {readFileLineByLine} from './internal/utils';
|
||||
|
||||
const stepName = 'Step-8';
|
||||
const stepDescriptiveName = 'LogHeuristics';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const mainLogFilePath = log.transports.file.getFile().path;
|
||||
const fileData = await readFileLineByLine(mainLogFilePath);
|
||||
|
||||
const linesCount = fileData.lines.length;
|
||||
const percentageOfErrors = getPercentage(fileData.logLevelAmounts.error, linesCount);
|
||||
|
||||
/**
|
||||
* Ideally we could define a threshold for the error % for which this step would return an appropriate message
|
||||
* and/or return all the errors
|
||||
*/
|
||||
const payload = {
|
||||
logLevels: fileData.logLevelAmounts,
|
||||
percentageOfErrors,
|
||||
linesCount,
|
||||
};
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step8 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step8;
|
39
src/main/diagnostics/steps/step9.config.ts
Normal file
39
src/main/diagnostics/steps/step9.config.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElectronLog} from 'electron-log';
|
||||
import {DiagnosticStepResponse} from 'types/diagnostics';
|
||||
|
||||
import config from 'common/config';
|
||||
|
||||
import DiagnosticsStep from '../DiagnosticStep';
|
||||
|
||||
const stepName = 'Step-9';
|
||||
const stepDescriptiveName = 'Config';
|
||||
|
||||
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
|
||||
try {
|
||||
const payload = config.data;
|
||||
|
||||
return {
|
||||
message: `${stepName} finished successfully`,
|
||||
succeeded: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Diagnostics ${stepName} Failure`, {error});
|
||||
return {
|
||||
message: `${stepName} failed`,
|
||||
succeeded: false,
|
||||
payload: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Step9 = new DiagnosticsStep({
|
||||
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
|
||||
retries: 0,
|
||||
run,
|
||||
});
|
||||
|
||||
export default Step9;
|
|
@ -316,4 +316,10 @@ describe('main/menus/app', () => {
|
|||
expect(menuItem).toBe(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('should show the "Run diagnostics" item under help', () => {
|
||||
const menu = createTemplate(config);
|
||||
const helpSubmenu = menu.find((subMenu) => subMenu.id === 'help')?.submenu;
|
||||
expect(helpSubmenu).toContainObject({id: 'diagnostics'});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,6 +14,7 @@ import {localizeMessage} from 'main/i18nManager';
|
|||
import WindowManager from 'main/windows/windowManager';
|
||||
import {UpdateManager} from 'main/autoUpdater';
|
||||
import downloadsManager from 'main/downloadsManager';
|
||||
import Diagnostics from 'main/diagnostics';
|
||||
|
||||
export function createTemplate(config: Config, updateManager: UpdateManager) {
|
||||
const separatorItem: MenuItemConstructorOptions = {
|
||||
|
@ -339,6 +340,15 @@ export function createTemplate(config: Config, updateManager: UpdateManager) {
|
|||
submenu.push(separatorItem);
|
||||
}
|
||||
|
||||
submenu.push({
|
||||
id: 'diagnostics',
|
||||
label: localizeMessage('main.menus.app.help.RunDiagnostics', 'Run diagnostics'),
|
||||
click() {
|
||||
Diagnostics.run();
|
||||
},
|
||||
});
|
||||
submenu.push(separatorItem);
|
||||
|
||||
const version = localizeMessage('main.menus.app.help.versionString', 'Version {version}{commit}', {
|
||||
version: app.getVersion(),
|
||||
// eslint-disable-next-line no-undef
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
import {net, session} from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
import {COOKIE_NAME_AUTH_TOKEN, COOKIE_NAME_CSRF, COOKIE_NAME_USER_ID} from 'common/constants';
|
||||
|
||||
export async function getServerAPI<T>(url: URL, isAuthenticated: boolean, onSuccess?: (data: T) => void, onAbort?: () => void, onError?: (error: Error) => void) {
|
||||
if (isAuthenticated) {
|
||||
const cookies = await session.defaultSession.cookies.get({});
|
||||
|
@ -15,9 +17,9 @@ export async function getServerAPI<T>(url: URL, isAuthenticated: boolean, onSucc
|
|||
// Filter out cookies that aren't part of our domain
|
||||
const filteredCookies = cookies.filter((cookie) => cookie.domain && url.toString().indexOf(cookie.domain) >= 0);
|
||||
|
||||
const userId = filteredCookies.find((cookie) => cookie.name === 'MMUSERID');
|
||||
const csrf = filteredCookies.find((cookie) => cookie.name === 'MMCSRF');
|
||||
const authToken = filteredCookies.find((cookie) => cookie.name === 'MMAUTHTOKEN');
|
||||
const userId = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_USER_ID);
|
||||
const csrf = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_CSRF);
|
||||
const authToken = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_AUTH_TOKEN);
|
||||
|
||||
if (!userId || !csrf || !authToken) {
|
||||
// Missing cookies needed for req
|
||||
|
|
51
src/types/diagnostics.ts
Normal file
51
src/types/diagnostics.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElectronLog, LogLevel} from 'electron-log';
|
||||
|
||||
export type DiagnosticsStepConstructorPayload = {
|
||||
name: string;
|
||||
retries: number;
|
||||
run: (logger: ElectronLog) => Promise<DiagnosticStepResponse>;
|
||||
}
|
||||
|
||||
export type DiagnosticStepResponse = {
|
||||
succeeded: boolean;
|
||||
message?: string;
|
||||
payload?: unknown;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export type DiagnosticsReportObject = DiagnosticStepResponse & {
|
||||
step: number;
|
||||
name: DiagnosticsStepConstructorPayload['name'];
|
||||
}
|
||||
|
||||
export type AddDurationToFnReturnObject =
|
||||
(run: (logger: ElectronLog) => Promise<DiagnosticStepResponse>)
|
||||
=> (logger: ElectronLog)
|
||||
=> Promise<Omit<DiagnosticStepResponse, 'duration'> & {duration: number}>;
|
||||
|
||||
export type DiagnosticsReport = DiagnosticsReportObject[];
|
||||
|
||||
export type WindowStatus = Array<{
|
||||
name: string;
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
}>;
|
||||
|
||||
export type LogFileLineData = {
|
||||
text: string;
|
||||
logLevel?: LogLevel;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export type LogLevelAmounts = {
|
||||
silly: number;
|
||||
debug: number;
|
||||
verbose: number;
|
||||
info: number;
|
||||
warn: number;
|
||||
error: number;
|
||||
}
|
||||
|
Loading…
Reference in a new issue