diff --git a/package-lock.json b/package-lock.json index 75c08b429..65c4b8af0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,7 +114,7 @@ "oclif": "^3.0.1", "prettier": "2.5.1", "ts-node": "^10.4.0", - "typescript": "4.4.4" + "typescript": "4.9.5" }, "engines": { "node": ">=14.0.0" @@ -13627,9 +13627,9 @@ } }, "node_modules/typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz", - "integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -25599,9 +25599,9 @@ } }, "typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz", - "integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==" + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" }, "uglify-js": { "version": "3.14.1", diff --git a/package.json b/package.json index 177020782..fb1a42fd5 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "oclif": "^3.0.1", "prettier": "2.5.1", "ts-node": "^10.4.0", - "typescript": "4.4.4" + "typescript": "4.9.5" }, "prettier": { "semi": false, diff --git a/src/components/logger/startup-message.test.ts b/src/components/logger/startup-message.test.ts new file mode 100644 index 000000000..e3eed7673 --- /dev/null +++ b/src/components/logger/startup-message.test.ts @@ -0,0 +1,391 @@ +/********************************************************************************** + * MIT License * + * * + * Copyright (c) 2021 Hyperjump Technology * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy * + * of this software and associated documentation files (the "Software"), to deal * + * in the Software without restriction, including without limitation the rights * + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * + * copies of the Software, and to permit persons to whom the Software is * + * furnished to do so, subject to the following conditions: * + * * + * The above copyright notice and this permission notice shall be included in all * + * copies or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * + * SOFTWARE. * + **********************************************************************************/ + +import { expect } from 'chai' +import type { Config } from '../../interfaces/config' +import type { MailgunData, SMTPData, WebhookData } from '../../interfaces/data' +import { generateStartupMessage } from './startup-message' + +const defaultConfig: Config = { + probes: [ + { + id: 'uYJaw', + name: 'Acme Inc.', + interval: 3000, + requests: [ + { url: 'https://example.com', headers: {}, body: '', timeout: 0 }, + ], + incidentThreshold: 1, + recoveryThreshold: 1, + alerts: [], + }, + ], + notifications: [{ id: 'UVIsL', type: 'desktop', data: undefined }], +} + +describe('Startup message', () => { + describe('Symon mode', () => { + it('should show running in Symon mode', () => { + // act + const message = generateStartupMessage({ + config: { probes: [] }, + isFirstRun: false, + isSymonMode: true, + isVerbose: false, + }) + + // assert + expect(message).eq('Running in Symon mode') + }) + }) + + describe('Monika mode', () => { + describe('Notification message', () => { + it('should show has no notification warning', () => { + // arrange + const message = generateStartupMessage({ + config: { probes: [] }, + isFirstRun: false, + isSymonMode: false, + isVerbose: false, + }) + + // assert + expect(message).include('Notifications has not been set') + }) + }) + + describe('Starting or Restarting', () => { + it('should show starting', () => { + // arrange + const message = generateStartupMessage({ + config: defaultConfig, + isFirstRun: true, + isSymonMode: false, + isVerbose: false, + }) + + // assert + expect(message).include('Starting') + }) + + it('should show restarting', () => { + // arrange + const message = generateStartupMessage({ + config: defaultConfig, + isFirstRun: false, + isSymonMode: false, + isVerbose: false, + }) + + // assert + expect(message).include('Restarting') + }) + + it('should show probe and notification total', () => { + // arrange + const message = generateStartupMessage({ + config: defaultConfig, + isFirstRun: true, + isSymonMode: false, + isVerbose: false, + }) + + // assert + expect(message).include('Probes: 1') + expect(message).include('Notifications: 1') + }) + + it('should show empty notification total', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + notifications: [], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: false, + }) + + // assert + expect(message).include('Notifications: 0') + }) + }) + }) + + describe('Verbose', () => { + describe('Probe detail', () => { + it('should show probe detail', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + probes: [ + { + ...defaultConfig.probes[0], + description: "Acme's company profile", + }, + ], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('uYJaw') + expect(message).include('Acme') + expect(message).include("Acme's company profile") + expect(message).include('-') + expect(message).include('3000') + }) + + it('should show probe detail without description', () => { + // arrange + const message = generateStartupMessage({ + config: { + probes: [ + { + id: 'uYJaw', + name: 'Acme Inc.', + interval: 3000, + requests: [], + incidentThreshold: 1, + recoveryThreshold: 1, + alerts: [], + }, + ], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('uYJaw') + expect(message).include('Acme Inc.') + expect(message).include('-') + expect(message).include('3000') + }) + }) + + describe('Probe request detail', () => { + it('should show probe request detail with default method', () => { + // arrange + const message = generateStartupMessage({ + config: defaultConfig, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('GET') + expect(message).include('https://example.com') + }) + + it('should show probe detail', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + probes: [ + ...defaultConfig.probes, + { + id: 'N3Omh', + name: 'Example', + interval: 3000, + requests: [ + { + method: 'POST', + url: 'https://example.com', + headers: {}, + body: '', + timeout: 0, + }, + ], + incidentThreshold: 1, + recoveryThreshold: 1, + alerts: [], + }, + ], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('POST') + expect(message).include('https://example.com') + }) + }) + + describe('Alert detail', () => { + it('should show default alert', () => { + // arrange + const message = generateStartupMessage({ + config: defaultConfig, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include( + 'response.status < 200 or response.status > 299' + ) + }) + + it('should show alert detail', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + probes: [ + { + ...defaultConfig.probes[0], + + alerts: [ + { + assertion: 'response.status = 500', + message: 'HTTP status is 500', + }, + ], + }, + ], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include( + '[{"assertion":"response.status = 500","message":"HTTP status is 500"}]' + ) + }) + }) + + describe('Notification detail', () => { + it('should show notification id and type', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + notifications: [{ id: 'UVIsL', type: 'desktop', data: undefined }], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('Notifications') + expect(message).include('UVIsL') + expect(message).include('desktop') + }) + + it('should show notification detail for SMTP', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + notifications: [ + { + id: '97AnH', + type: 'smtp', + data: { + hostname: 'example.com', + port: 25, + username: 'name@example.com', + recipients: ['john@example.com', 'jane@example.com'], + } as SMTPData, + }, + ], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('john@example.com, jane@example.com') + expect(message).include('example.com') + expect(message).include('25') + expect(message).include('name@example.com') + }) + + it('should show notification detail with domain', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + notifications: [ + { + id: 'N3Omh', + type: 'mailgun', + data: { + recipients: ['john@example.com'], + domain: 'https://example.com', + } as MailgunData, + }, + ], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('https://example.com') + }) + + it('should show notification detail with url', () => { + // arrange + const message = generateStartupMessage({ + config: { + ...defaultConfig, + notifications: [ + { + id: 'SxUhV', + type: 'webhook', + data: { + url: 'https://example.com', + } as WebhookData, + }, + ], + }, + isFirstRun: true, + isSymonMode: false, + isVerbose: true, + }) + + // assert + expect(message).include('https://example.com') + }) + }) + }) +}) diff --git a/src/components/logger/startup-message.ts b/src/components/logger/startup-message.ts new file mode 100644 index 000000000..f1e5fdb5c --- /dev/null +++ b/src/components/logger/startup-message.ts @@ -0,0 +1,171 @@ +import boxen from 'boxen' +import chalk from 'chalk' +import type { Config } from '../../interfaces/config' +import type { Notification } from '../../interfaces/notification' +import type { Probe, ProbeAlert } from '../../interfaces/probe' +import type { RequestConfig } from '../../interfaces/request' + +type GenerateStartupMessageParams = { + config: Config + isFirstRun: boolean + isVerbose: boolean + isSymonMode: boolean +} + +export function generateStartupMessage({ + config, + isFirstRun, + isVerbose, + isSymonMode, +}: GenerateStartupMessageParams): string { + if (isSymonMode) { + return 'Running in Symon mode' + } + + const { notifications = [], probes } = config + const notificationTotal = notifications.length + const probeTotal = probes.length + const hasNotification = notificationTotal > 0 + + let startupMessage = '' + + // warn if config is empty + if (!hasNotification) { + startupMessage += generateEmptyNotificationMessage() + } + + startupMessage += generateConfigInfoMessage({ + isFirstRun, + notificationTotal, + probeTotal, + }) + + if (isVerbose) { + startupMessage += generateProbeMessage(probes) + startupMessage += generateNotificationMessage(notifications || []) + } + + return startupMessage +} + +function generateEmptyNotificationMessage(): string { + const NO_NOTIFICATIONS_MESSAGE = `Notifications has not been set. We will not be able to notify you when an INCIDENT occurs! + Please refer to the Monika documentations on how to how to configure notifications (e.g., Telegram, Slack, Desktop notification, etc.) at https://monika.hyperjump.tech/guides/notifications.` + + return boxen(chalk.yellow(NO_NOTIFICATIONS_MESSAGE), { + padding: 1, + margin: { + top: 2, + right: 1, + bottom: 2, + left: 1, + }, + borderStyle: 'bold', + borderColor: 'yellow', + }) +} + +type GenerateConfigInfoMessageParams = { + isFirstRun: boolean + notificationTotal: number + probeTotal: number +} + +function generateConfigInfoMessage({ + isFirstRun, + notificationTotal, + probeTotal, +}: GenerateConfigInfoMessageParams) { + return `${ + isFirstRun ? 'Starting' : 'Restarting' + } Monika. Probes: ${probeTotal}. Notifications: ${notificationTotal}\n\n` +} + +function generateProbeMessage(probes: Probe[]): string { + let startupMessage = 'Probes:\n' + + for (const probe of probes) { + const { alerts, description, id, interval, name, requests } = probe + + startupMessage += `- Probe ID: ${id} +Name: ${name} +Description: ${description || '-'} +Interval: ${interval} +` + startupMessage += ` Requests:\n` + startupMessage += generateProbeRequestMessage(requests) + startupMessage += generateAlertMessage(alerts) + } + + return startupMessage +} + +function generateProbeRequestMessage(requests: RequestConfig[]): string { + let startupMessage = '' + + for (const request of requests) { + const { body, headers, method, url } = request + + startupMessage += ` - Request Method: ${method || `GET`} + Request URL: ${url} + Request Headers: ${JSON.stringify(headers) || `-`} + Request Body: ${JSON.stringify(body) || `-`} +` + } + + return startupMessage +} + +function generateAlertMessage(alerts: ProbeAlert[]): string { + const hasAlert = alerts.length > 0 + const defaultAlertsInString = + '[{ "assertion": "response.status < 200 or response.status > 299", "message": "HTTP Status is not 200"}, { "assertion": "response.time > 2000", "message": "Response time is more than 2000ms" }]' + const alertsInString = JSON.stringify(alerts) + + return ` Alerts: ${hasAlert ? alertsInString : defaultAlertsInString}\n` +} + +function generateNotificationMessage(notifications: Notification[]): string { + const hasNotification = notifications.length > 0 + + if (!hasNotification) { + return '' + } + + let startupMessage = `\nNotifications:\n` + + for (const notification of notifications) { + const { data, id, type } = notification + + startupMessage += `- Notification ID: ${id} +Type: ${type} +` + // Only show recipients if type is mailgun, smtp, or sendgrid + // check one-by-one instead of using indexOf to avoid using type assertion + if (type === 'mailgun' || type === 'smtp' || type === 'sendgrid') { + startupMessage += ` Recipients: ${data.recipients.join(', ')}\n` + } + + switch (type) { + case 'smtp': + startupMessage += ` Hostname: ${data.hostname} +Port: ${data.port} +Username: ${data.username} +` + break + case 'mailgun': + startupMessage += ` Domain: ${data.domain}\n` + break + case 'sendgrid': + break + case 'webhook': + case 'slack': + case 'lark': + case 'google-chat': + startupMessage += ` URL: ${data.url}\n` + break + } + } + + return startupMessage +} diff --git a/src/index.ts b/src/index.ts index cebbec412..fada9b264 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,8 +22,6 @@ * SOFTWARE. * **********************************************************************************/ -import boxen from 'boxen' -import chalk from 'chalk' import isUrl from 'is-url' import cron, { ScheduledTask } from 'node-cron' import path from 'path' @@ -49,6 +47,7 @@ import { flushAllLogs, openLogfile, } from './components/logger/history' +import { generateStartupMessage } from './components/logger/startup-message' import { notificationChecker } from './components/notification/checker' import { setContext } from './context' import events from './events' @@ -366,12 +365,12 @@ class Monika extends Command { await this.deprecationHandler(config) - const startupMessage = this.buildStartupMessage( + const startupMessage = generateStartupMessage({ config, - !abortCurrentLooper, - _flags.verbose, - isSymonMode - ) + isFirstRun: !abortCurrentLooper, + isSymonMode, + isVerbose: _flags.verbose, + }) // Display config files being used if (isSymonMode) { @@ -453,115 +452,7 @@ class Monika extends Command { this.error((error as any)?.message, { exit: 1 }) } } - - buildStartupMessage( - config: Config, - firstRun: boolean, - verbose = false, - isSymonMode = false - ): string { - if (isSymonMode) { - return 'Running in Symon mode' - } - - const { probes, notifications } = config - - let startupMessage = '' - - // warn if config is empty - if ((notifications?.length ?? 0) === 0) { - const NO_NOTIFICATIONS_MESSAGE = `Notifications has not been set. We will not be able to notify you when an INCIDENT occurs! -Please refer to the Monika documentations on how to how to configure notifications (e.g., Telegram, Slack, Desktop notification, etc.) at https://monika.hyperjump.tech/guides/notifications.` - - startupMessage += boxen(chalk.yellow(NO_NOTIFICATIONS_MESSAGE), { - padding: 1, - margin: { - top: 2, - right: 1, - bottom: 2, - left: 1, - }, - borderStyle: 'bold', - borderColor: 'yellow', - }) - } - - startupMessage += `${ - firstRun ? 'Starting' : 'Restarting' - } Monika. Probes: ${probes.length}. Notifications: ${ - notifications?.length ?? 0 - }\n\n` - - if (verbose) { - startupMessage += 'Probes:\n' - - for (const probe of probes) { - startupMessage += `- Probe ID: ${probe.id} - Name: ${probe.name} - Description: ${probe.description} - Interval: ${probe.interval} -` - startupMessage += ` Requests:\n` - for (const request of probe.requests) { - startupMessage += ` - Request Method: ${request.method || `GET`} - Request URL: ${request.url} - Request Headers: ${JSON.stringify(request.headers) || `-`} - Request Body: ${JSON.stringify(request.body) || `-`} -` - } - - startupMessage += ` Alerts: ${ - probe?.alerts === undefined || probe?.alerts.length === 0 - ? `[{ "assertion": "response.status < 200 or response.status > 299", "message": "HTTP Status is not 200"}, - { "assertion": "response.time > 2000", "message": "Response time is more than 2000ms" }]` - : JSON.stringify(probe.alerts) - }\n` - } - - if (notifications && notifications.length > 0) { - startupMessage += `\nNotifications:\n` - - for (const item of notifications) { - startupMessage += `- Notification ID: ${item.id} - Type: ${item.type} -` - // Only show recipients if type is mailgun, smtp, or sendgrid - // check one-by-one instead of using indexOf to avoid using type assertion - if ( - item.type === 'mailgun' || - item.type === 'smtp' || - item.type === 'sendgrid' - ) { - startupMessage += ` Recipients: ${item.data.recipients.join( - ', ' - )}\n` - } - - switch (item.type) { - case 'smtp': - startupMessage += ` Hostname: ${item.data.hostname} - Port: ${item.data.port} - Username: ${item.data.username} -` - break - case 'mailgun': - startupMessage += ` Domain: ${item.data.domain}\n` - break - case 'sendgrid': - break - case 'webhook': - case 'slack': - case 'lark': - case 'google-chat': - startupMessage += ` URL: ${item.data.url}\n` - break - } - } - } - } - - return startupMessage - } + /* eslint-enable complexity */ async catch(error: Error): Promise { super.catch(error)