diff --git a/db/migrations/20230112152900.sql b/db/migrations/20230112152900.sql new file mode 100644 index 000000000..791563ea9 --- /dev/null +++ b/db/migrations/20230112152900.sql @@ -0,0 +1,15 @@ +-- instatus_page_incidents definition + +-- Drop Table +DROP TABLE IF EXISTS instatus_page_incidents; +-- Create Table +CREATE TABLE instatus_page_incidents ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL, + url TEXT NOT NULL, + probe_id INTEGER NOT NULL, + incident_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL); + +CREATE INDEX instatus_page_incidents_incident_id_IDX ON instatus_page_incidents (incident_id); diff --git a/docs/src/pages/guides/notifications.md b/docs/src/pages/guides/notifications.md index 3a032248d..4cfa114d9 100644 --- a/docs/src/pages/guides/notifications.md +++ b/docs/src/pages/guides/notifications.md @@ -28,6 +28,7 @@ At this moment, Monika support these channel of notifications (You can use just 17. [Pushover](https://hyperjumptech.github.io/monika/guides/notifications#pushover) 18. [Opsgenie](https://hyperjumptech.github.io/monika/guides/notifications#opsgenie) 19. [Pushbullet](https://hyperjumptech.github.io/monika/guides/notifications#pushbullet) +20. [Instatus](https://hyperjumptech.github.io/monika/guides/notifications#instatus) ## Configurations @@ -510,3 +511,23 @@ notifications: | -------- | ---------------------------- | ---------------------- | | token | Pushbullet Access Token | `a6FJVAA0LVJKrT8k` | | deviceID | Pushbullet Device Identifier | `ujpah72o0sjAoRtnM0jc` | + +## Instatus + +[Instatus](https://instatus.com/) is a status and incident communication tool. You need a page ID and an API key to use Instatus. You can obtain it by following the steps in [the documentation](https://dashboard.instatus.com/developer). + +```yaml +notifications: + - id: unique-id-instatus + type: instatus + data: + apiKey: YOUR_INSTATUS_API_KEY + pageID: YOUR_INSTATUS_PAGE_ID //You can get it with client.pages.get() +``` + +| Key | Description | Example | +| ---------------- | ---------------------------- | ---------------------------------- | +| id | Notification identity number | `instatus-id` | +| type | Notification types | `instatus` | +| data.apiKey | Instatus API key | `43d43d2c06ae223a88a9c35523acd00a` | +| data.data.pageID | Instatus page ID | `2hu1aj8r6td7mog6uz1sh` | diff --git a/monika.example.yml b/monika.example.yml index d218e0d26..469f65d75 100644 --- a/monika.example.yml +++ b/monika.example.yml @@ -197,6 +197,11 @@ probes: # type: opsgenie # data: # geniekey: "genie-key" +# - id: random-string-instatus +# type: instatus +# data: +# apiKey: YOUR_INSTATUS_API_KEY +# pageID: YOUR_INSTATUS_PAGE_ID # limit log database size in bytes db_limit: diff --git a/src/components/config/validation/notification-required-fields.ts b/src/components/config/validation/notification-required-fields.ts index a7ff779cf..26aef8d54 100644 --- a/src/components/config/validation/notification-required-fields.ts +++ b/src/components/config/validation/notification-required-fields.ts @@ -87,4 +87,8 @@ export const requiredFieldMessages: Record = { token: 'Pushbullet Access Token not found! You can create your Access Token at https://www.pushbullet.com/#settings', }, + instatus: { + apiKey: 'apiKey not found', + pageID: 'pageID not found', + }, } diff --git a/src/components/config/validation/validator/notification.ts b/src/components/config/validation/validator/notification.ts index 447adc165..4ccb22ba1 100644 --- a/src/components/config/validation/validator/notification.ts +++ b/src/components/config/validation/validator/notification.ts @@ -27,6 +27,10 @@ import { slug as atlassianStatuspageSlug, validateConfig as atlassianStatuspageValidateConfig, } from '../../../../plugins/visualization/atlassian-status-page' +import { + slug as instatusPageSlug, + validateConfig as instatusPageValidateConfig, +} from '../../../../plugins/visualization/instatus' import { newPagerDuty } from '../../../notification/channel/pagerduty' import { requiredFieldMessages } from '../notification-required-fields' @@ -78,6 +82,13 @@ const checkByNotificationType = ( return pagerduty.validateConfig(notification.data) } + if ( + notification.type === instatusPageSlug && + instatusPageValidateConfig(notification.data) + ) { + return instatusPageValidateConfig(notification.data) + } + const missingField = validateRequiredFields(notification) if (missingField) { return missingField diff --git a/src/components/logger/history.ts b/src/components/logger/history.ts index fbf01b45c..bde3a62a4 100644 --- a/src/components/logger/history.ts +++ b/src/components/logger/history.ts @@ -408,6 +408,8 @@ export async function deleteNotificationLogs( export async function flushAllLogs(): Promise { const dropAtlassianStatusPageTableSQL = 'DROP TABLE IF EXISTS atlassian_status_page_incidents;' + const dropInstatusPageTableSQL = + 'DROP TABLE IF EXISTS instatus_page_incidents;' const dropProbeRequestsTableSQL = 'DROP TABLE IF EXISTS probe_requests;' const dropAlertsTableSQL = 'DROP TABLE IF EXISTS alerts;' const dropNotificationsTableSQL = 'DROP TABLE IF EXISTS notifications;' @@ -419,6 +421,7 @@ export async function flushAllLogs(): Promise { db.run(dropAlertsTableSQL), db.run(dropNotificationsTableSQL), db.run(dropMigrationsTableSQL), + db.run(dropInstatusPageTableSQL), // The VACUUM command cleans the main database by copying its contents to a temporary database file and reloading the original database file from the copy. // This eliminates free pages, aligns table data to be contiguous, and otherwise cleans up the database file structure. diff --git a/src/components/notification/checker.ts b/src/components/notification/checker.ts index b4177c70b..571580004 100644 --- a/src/components/notification/checker.ts +++ b/src/components/notification/checker.ts @@ -26,6 +26,7 @@ import { hostname } from 'os' import { NotificationSendingError, sendNotifications } from '.' import { Notification } from '../../interfaces/notification' import { validator as dataStatuspageSchemaValidator } from '../../plugins/visualization/atlassian-status-page' +import { validator as dataInstatusSchemaValidator } from '../../plugins/visualization/instatus' import getIp from '../../utils/ip' import { getMessageForStart } from './alert-message' import { newPagerDuty } from './channel/pagerduty' @@ -78,6 +79,7 @@ export const notificationChecker = async ( pushover: dataPushoverSchemaValidator, gotify: dataGotifySchemaValidator, pushbullet: dataPushbulletSchemaValidator, + instatus: dataInstatusSchemaValidator, } await Promise.all( diff --git a/src/events/subscribers/probe.ts b/src/events/subscribers/probe.ts index 5516ece57..d617c1242 100644 --- a/src/events/subscribers/probe.ts +++ b/src/events/subscribers/probe.ts @@ -26,6 +26,8 @@ import events from '../../events' import type { Notification } from '../../interfaces/notification' import type { StatuspageNotification } from '../../plugins/visualization/atlassian-status-page' import { AtlassianStatusPageAPI } from '../../plugins/visualization/atlassian-status-page' +import type { InstatusPageNotification } from '../../plugins/visualization/instatus' +import { InstatusPageAPI } from '../../plugins/visualization/instatus' import { getEventEmitter } from '../../utils/events' import { log } from '../../utils/pino' @@ -64,6 +66,36 @@ eventEmitter.on( ) } } + + const isInstatuspageEnable: InstatusPageNotification | undefined = + notifications.find( + (notification: Notification) => notification.type === 'instatus' + ) + + if (!isNotificationEmpty && isInstatuspageEnable) { + const { apiKey, pageID } = isInstatuspageEnable.data + const instatusPageAPI = new InstatusPageAPI(apiKey, pageID) + const type = getNotificationType(probeState) + + try { + if (!type) { + throw new Error(`probeState ${probeState} is unknown`) + } + + const incidentID = await instatusPageAPI.notify({ + probeID, + type, + url, + }) + log.info( + `Instatus page (${type}). id: ${incidentID}, probeID: ${probeID}, url: ${url}` + ) + } catch (error: any) { + log.error( + `Instatus page (Error). probeID: ${probeID}, url: ${url}, probeState: ${probeState} error: ${error}` + ) + } + } } ) diff --git a/src/interfaces/notification.ts b/src/interfaces/notification.ts index 241866ca9..d294da968 100644 --- a/src/interfaces/notification.ts +++ b/src/interfaces/notification.ts @@ -24,6 +24,7 @@ import { PagerDutyNotification } from '../components/notification/channel/pagerduty' import type { StatuspageNotification } from '../plugins/visualization/atlassian-status-page' +import type { InstatusPageNotification } from '../plugins/visualization/instatus' import { MailgunData, MonikaNotifData, @@ -167,6 +168,7 @@ export type Notification = | OpsgenieNotification | StatuspageNotification | PushbulletNotification + | InstatusPageNotification interface BaseNotificationMessageMeta { type: string diff --git a/src/monika-config-schema.json b/src/monika-config-schema.json index d0056dc0b..a2049b9e9 100644 --- a/src/monika-config-schema.json +++ b/src/monika-config-schema.json @@ -1141,6 +1141,38 @@ } } } + }, + { + "title": "Instatus", + "type": "object", + "required": ["id", "type", "data"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The type of notification", + "default": "instatus" + }, + "type": { + "const": "instatus" + }, + "data": { + "type": "object", + "description": "Data for your payload", + "additionalProperties": false, + "required": ["apiKey", "pageID"], + "properties": { + "apiKey": { + "type": "string", + "description": "Instatus API key" + }, + "pageID": { + "type": "string", + "description": "Instatus Page ID" + } + } + } + } } ] } diff --git a/src/plugins/visualization/instatus/database.ts b/src/plugins/visualization/instatus/database.ts new file mode 100644 index 000000000..1c28b02b8 --- /dev/null +++ b/src/plugins/visualization/instatus/database.ts @@ -0,0 +1,62 @@ +import { db } from '../../../components/logger/history' + +type Incident = { + id: string + status: string + url: string + probeID: string + incidentID: string +} +type InsertIncident = Omit +type UpdateIncident = Pick +type FindIncident = Pick + +type FindIncidentResponse = { + // eslint-disable-next-line camelcase + incident_id: string +} + +export async function insertIncident({ + status, + url, + probeID, + incidentID, +}: InsertIncident): Promise { + const dateNow = Math.round(Date.now() / 1000) + const sqlStatement = `INSERT INTO instatus_page_incidents ( + status, + url, + probe_id, + incident_id, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?);` + const sqlParams = [status, url, probeID, incidentID, dateNow, dateNow] + + await db.run(sqlStatement, sqlParams) +} + +export async function updateIncident({ + incidentID, + status, +}: UpdateIncident): Promise { + const dateNow = Math.round(Date.now() / 1000) + const sqlStatement = `UPDATE instatus_page_incidents SET status = ?, updated_at = ? + WHERE incident_id = ?` + const sqlParams = [status, dateNow, incidentID] + + await db.run(sqlStatement, sqlParams) +} + +export async function findIncident({ + probeID, + status, + url, +}: FindIncident): Promise { + const sqlStatement = `SELECT incident_id FROM instatus_page_incidents + WHERE status = ? AND url = ? AND probe_id = ?` + const sqlParams = [status, url, probeID] + const incident = await db.get(sqlStatement, sqlParams) + + return incident +} diff --git a/src/plugins/visualization/instatus/index.ts b/src/plugins/visualization/instatus/index.ts new file mode 100644 index 000000000..38161fc4e --- /dev/null +++ b/src/plugins/visualization/instatus/index.ts @@ -0,0 +1,228 @@ +/********************************************************************************** + * 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 axios from 'axios' +import Joi from 'joi' +import { + findIncident, + insertIncident as insertIncidentToDatabase, + updateIncident as updateIncidentToDatabase, +} from './database' +import http from 'http' +import https from 'https' + +type NotifyIncident = { + probeID: string + url: string + type: 'incident' | 'recovery' +} +type Incident = Omit + +type InstatusConfig = { + apiKey: string + pageID: string +} + +export type InstatusPageNotification = { + id: string + type: 'instatus' + data: InstatusConfig +} + +type Component = { + id: string + name: string + description: string + status: string + uniqueEmail: string + showUptime: boolean + order: number + group: 'string' | null +} + +export const slug = 'instatus' +export const validator = Joi.object({ + apiKey: Joi.string().required().label('API Key'), + pageID: Joi.string().required().label('Page ID'), +}).required() + +export const validateConfig = (instatusPageConfig: InstatusConfig): string => { + const { error } = validator.validate(instatusPageConfig) + + return error ? `Instatus notification: ${error?.message}` : '' +} + +export class InstatusPageAPI { + private instatusPageBaseURL = 'https://api.instatus.com' + private axiosConfig = {} + private pageID = '' + + constructor(apiKey: string, pageID: string) { + this.axiosConfig = { + // 10 sec timeout + timeout: 10_000, + // keepAlive pools and reuses TCP connections, so it's faster + httpAgent: new http.Agent({ keepAlive: true }), + httpsAgent: new https.Agent({ keepAlive: true }), + // follow up to 10 HTTP 3xx redirects + maxRedirects: 10, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + } + + this.pageID = pageID + } + + async notify({ probeID, url, type }: NotifyIncident): Promise { + switch (type) { + case 'incident': { + const incidentID = await this.createIncident({ probeID, url }) + + return incidentID + } + + case 'recovery': { + const incidentID = await this.updateIncident({ probeID, url }) + + return incidentID + } + + default: + throw new Error(`Instatus notification: type ${type} is unknown`) + } + } + + private async createIncident({ probeID, url }: Incident): Promise { + const status = 'INVESTIGATING' + const incident = await findIncident({ + probeID, + status, + url, + }) + + // prevent duplicate incident + if (incident) { + return incident.incident_id + } + + try { + const components = await this.getComponents() + const componentIDs: string[] = components.map(({ id }) => id) + const componentID: string = componentIDs[0] + const started = new Date() + const statuses = componentIDs.map((id) => ({ + id, + status: 'MAJOROUTAGE', + })) + const data = { + name: 'Service is down', + message: "We're currently experiencing an issue with the website", + components: [componentID], + started, + status, + notify: true, + statuses, + } + + const incidentID = await axios + .post( + `${this.instatusPageBaseURL}/v1/${this.pageID}/incidents/`, + data, + this.axiosConfig + ) + .then((res) => { + return res?.data?.id + }) + + insertIncidentToDatabase({ incidentID, probeID, status, url }) + + return incidentID + } catch (error: any) { + throw new Error( + `${error?.message}${ + error?.data ? `. ${error?.response?.data?.message}` : '' + }` + ) + } + } + + private async updateIncident({ probeID, url }: Incident): Promise { + const incident = await findIncident({ + probeID, + status: 'INVESTIGATING', + url, + }) + + if (!incident) { + throw new Error('Instatus notification: Incident is not found.') + } + + const status = 'RESOLVED' + const components = await this.getComponents() + const componentIDs: string[] = components.map(({ id }) => id) + const componentID: string = componentIDs[0] + const started = new Date() + const statuses = componentIDs.map((id) => ({ + id, + status: 'OPERATIONAL', + })) + const data = { + name: 'Service is up', + components: [componentID], + started, + status, + statuses, + } + const { incident_id: incidentID } = incident + + try { + await axios.patch( + `${this.instatusPageBaseURL}/v1/${this.pageID}/incidents/${incidentID}`, + data, + this.axiosConfig + ) + } catch (error: any) { + throw new Error( + `${error?.message}${ + error?.data ? `. ${error?.response?.data?.message}` : '' + }` + ) + } + + await updateIncidentToDatabase({ incidentID, status }) + + return incidentID + } + + private async getComponents(): Promise { + const componentsResponse = await axios.get( + `${this.instatusPageBaseURL}/v1/${this.pageID}/components`, + this.axiosConfig + ) + + return componentsResponse.data + } +}