diff --git a/apps/api/src/app/integrations/dtos/credentials.dto.ts b/apps/api/src/app/integrations/dtos/credentials.dto.ts index 969525bd7f1..95049a0a749 100644 --- a/apps/api/src/app/integrations/dtos/credentials.dto.ts +++ b/apps/api/src/app/integrations/dtos/credentials.dto.ts @@ -171,4 +171,29 @@ export class CredentialsDto implements ICredentials { @IsString() @IsOptional() instanceId?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + alertUid?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + title?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + imageUrl?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + state?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + externalLink?: string; } diff --git a/apps/api/src/app/shared/dtos/subscriber-channel.ts b/apps/api/src/app/shared/dtos/subscriber-channel.ts index cb223671a7b..2c14d71907f 100644 --- a/apps/api/src/app/shared/dtos/subscriber-channel.ts +++ b/apps/api/src/app/shared/dtos/subscriber-channel.ts @@ -15,6 +15,31 @@ export class ChannelCredentials { description: 'Contains an array of the subscriber device tokens for a given provider. Used on Push integrations', }) deviceTokens?: string[]; + + @ApiPropertyOptional({ + description: 'alert_uid for grafana on-call webhook payload', + }) + alertUid?: string; + + @ApiPropertyOptional({ + description: 'title to be used with grafana on call webhook', + }) + title?: string; + + @ApiPropertyOptional({ + description: 'image_url property fo grafana on call webhook', + }) + imageUrl?: string; + + @ApiPropertyOptional({ + description: 'state property fo grafana on call webhook', + }) + state?: string; + + @ApiPropertyOptional({ + description: 'link_to_upstream_details property fo grafana on call webhook', + }) + externalUrl?: string; } export class SubscriberChannel { diff --git a/apps/web/public/static/images/providers/dark/grafana-on-call.png b/apps/web/public/static/images/providers/dark/grafana-on-call.png new file mode 100644 index 00000000000..caccf8b4aaf Binary files /dev/null and b/apps/web/public/static/images/providers/dark/grafana-on-call.png differ diff --git a/apps/web/public/static/images/providers/dark/square/grafana-on-call.svg b/apps/web/public/static/images/providers/dark/square/grafana-on-call.svg new file mode 100644 index 00000000000..09579340d1d --- /dev/null +++ b/apps/web/public/static/images/providers/dark/square/grafana-on-call.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/static/images/providers/light/grafana-on-call.png b/apps/web/public/static/images/providers/light/grafana-on-call.png new file mode 100644 index 00000000000..caccf8b4aaf Binary files /dev/null and b/apps/web/public/static/images/providers/light/grafana-on-call.png differ diff --git a/apps/web/public/static/images/providers/light/square/grafana-on-call.svg b/apps/web/public/static/images/providers/light/square/grafana-on-call.svg new file mode 100644 index 00000000000..09579340d1d --- /dev/null +++ b/apps/web/public/static/images/providers/light/square/grafana-on-call.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts b/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts index 26e3184cff5..0aa914f9839 100644 --- a/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts +++ b/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts @@ -16,6 +16,7 @@ const providers: Record = { ChatProviderIdEnum.MsTeams, ChatProviderIdEnum.Discord, ChatProviderIdEnum.Mattermost, + ChatProviderIdEnum.GrafanaOnCall, ], [ChannelTypeEnum.EMAIL]: [ EmailProviderIdEnum.SendGrid, diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 5f42f9f89dc..492956caa67 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -53,6 +53,11 @@ const integrationSchema = new Schema( authenticateByToken: Schema.Types.Boolean, authenticationTokenKey: Schema.Types.String, instanceId: Schema.Types.String, + alertUid: Schema.Types.String, + title: Schema.Types.String, + imageUrl: Schema.Types.String, + state: Schema.Types.String, + externalLink: Schema.Types.String, }, active: { type: Schema.Types.Boolean, diff --git a/libs/shared/src/consts/providers/channels/chat.ts b/libs/shared/src/consts/providers/channels/chat.ts index 0d504bf8943..ee294985140 100644 --- a/libs/shared/src/consts/providers/channels/chat.ts +++ b/libs/shared/src/consts/providers/channels/chat.ts @@ -1,5 +1,5 @@ import { IConfigCredentials, IProviderConfig } from '../provider.interface'; -import { slackConfig } from '../credentials'; +import { grafanaOnCallConfig, slackConfig } from '../credentials'; import { ChatProviderIdEnum } from '../provider.enum'; import { ChannelTypeEnum } from '../../../types'; @@ -21,6 +21,14 @@ export const chatProviders: IProviderConfig[] = [ docReference: 'https://docs.novu.co/channels-and-providers/chat/discord', logoFileName: { light: 'discord.svg', dark: 'discord.svg' }, }, + { + id: ChatProviderIdEnum.GrafanaOnCall, + displayName: 'Grafana On Call Webhook', + channel: ChannelTypeEnum.CHAT, + credentials: grafanaOnCallConfig, + docReference: 'https://grafana.com/docs/oncall/latest/integrations/webhook/', + logoFileName: { light: 'grafana-on-call.png', dark: 'grafana-on-call.png' }, + }, { id: ChatProviderIdEnum.MsTeams, displayName: 'MSTeams', diff --git a/libs/shared/src/consts/providers/credentials/provider-credentials.ts b/libs/shared/src/consts/providers/credentials/provider-credentials.ts index 1ed1561e628..d3c36b32a00 100644 --- a/libs/shared/src/consts/providers/credentials/provider-credentials.ts +++ b/libs/shared/src/consts/providers/credentials/provider-credentials.ts @@ -475,6 +475,45 @@ export const slackConfig: IConfigCredentials[] = [ }, ]; +export const grafanaOnCallConfig: IConfigCredentials[] = [ + { + key: CredentialsKeyEnum.alertUid, + displayName: 'Alert UID', + type: 'string', + description: 'a unique alert ID for grouping, maps to alert_uid of grafana webhook body content', + required: false, + }, + { + key: CredentialsKeyEnum.title, + displayName: 'Title.', + type: 'string', + description: 'title for the alert', + required: false, + }, + { + key: CredentialsKeyEnum.imageUrl, + displayName: 'Image URL', + type: 'string', + description: 'a URL for an image attached to alert, maps to image_url of grafana webhook body content', + required: false, + }, + { + key: CredentialsKeyEnum.state, + displayName: 'Alert State', + type: 'string', + description: 'either "ok" or "alerting". Helpful for auto-resolving', + required: false, + }, + { + key: CredentialsKeyEnum.externalLink, + displayName: 'External Link', + type: 'string', + description: + 'link back to your monitoring system, maps to "link_to_upstream_details" of grafana webhook body content', + required: false, + }, +]; + export const fcmConfig: IConfigCredentials[] = [ { key: CredentialsKeyEnum.ServiceAccount, diff --git a/libs/shared/src/consts/providers/provider.enum.ts b/libs/shared/src/consts/providers/provider.enum.ts index e260d3c4051..c443248a28b 100644 --- a/libs/shared/src/consts/providers/provider.enum.ts +++ b/libs/shared/src/consts/providers/provider.enum.ts @@ -38,6 +38,11 @@ export enum CredentialsKeyEnum { ApiToken = 'apiToken', ApiURL = 'apiURL', AppID = 'appID', + alertUid = 'alertUid', + title = 'title', + imageUrl = 'imageUrl', + state = 'state', + externalLink = 'externalLink', } export enum EmailProviderIdEnum { @@ -100,6 +105,7 @@ export enum ChatProviderIdEnum { Mattermost = 'mattermost', Ryver = 'ryver', Zulip = 'zulip', + GrafanaOnCall = 'grafana-on-call', } export enum PushProviderIdEnum { diff --git a/libs/shared/src/entities/integration/credential.interface.ts b/libs/shared/src/entities/integration/credential.interface.ts index 903aa4e60fd..43461059350 100644 --- a/libs/shared/src/entities/integration/credential.interface.ts +++ b/libs/shared/src/entities/integration/credential.interface.ts @@ -36,4 +36,9 @@ export interface ICredentials { apiToken?: string; apiURL?: string; appID?: string; + alertUid?: string; + title?: string; + imageUrl?: string; + state?: string; + externalLink?: string; } diff --git a/packages/application-generic/package.json b/packages/application-generic/package.json index 4c0c7585778..e328c167e26 100644 --- a/packages/application-generic/package.json +++ b/packages/application-generic/package.json @@ -77,6 +77,7 @@ "@novu/mattermost": "^0.21.0", "@novu/messagebird": "^0.21.0", "@novu/ms-teams": "^0.21.0", + "@novu/grafana-on-call": "^0.21.0", "@novu/netcore": "^0.21.0", "@novu/nodemailer": "^0.21.0", "@novu/one-signal": "^0.21.0", diff --git a/packages/application-generic/src/factories/chat/chat.factory.ts b/packages/application-generic/src/factories/chat/chat.factory.ts index 8ff9beb5ba3..f132f73a2d3 100644 --- a/packages/application-generic/src/factories/chat/chat.factory.ts +++ b/packages/application-generic/src/factories/chat/chat.factory.ts @@ -4,6 +4,7 @@ import { IntegrationEntity } from '@novu/dal'; import { DiscordHandler } from './handlers/discord.handler'; import { MSTeamsHandler } from './handlers/msteams.handler'; import { MattermostHandler } from './handlers/mattermost.handler'; +import { GrafanaOnCallHandler } from './handlers/grafana-on-call.handler'; import { RyverHandler } from './handlers/ryver.handler'; import { ZulipHandler } from './handlers/zulip.handler'; @@ -15,6 +16,7 @@ export class ChatFactory implements IChatFactory { new MattermostHandler(), new RyverHandler(), new ZulipHandler(), + new GrafanaOnCallHandler(), ]; getHandler(integration: IntegrationEntity) { diff --git a/packages/application-generic/src/factories/chat/handlers/grafana-on-call.handler.ts b/packages/application-generic/src/factories/chat/handlers/grafana-on-call.handler.ts new file mode 100644 index 00000000000..571f2f1111e --- /dev/null +++ b/packages/application-generic/src/factories/chat/handlers/grafana-on-call.handler.ts @@ -0,0 +1,15 @@ +import { ICredentials } from '@novu/shared'; +import { ChannelTypeEnum } from '@novu/stateless'; +import { GrafanaOnCallChatProvider } from '@novu/grafana-on-call'; + +import { BaseChatHandler } from './base.handler'; + +export class GrafanaOnCallHandler extends BaseChatHandler { + constructor() { + super('grafana-on-call', ChannelTypeEnum.CHAT); + } + + buildProvider(credentials: ICredentials) { + this.provider = new GrafanaOnCallChatProvider(credentials); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a2edb0fb20..757d838c8ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2149,6 +2149,9 @@ importers: '@novu/generic-sms': specifier: ^0.21.0 version: link:../../providers/generic-sms + '@novu/grafana-on-call': + specifier: ^0.21.0 + version: link:../../providers/grafana-on-call '@novu/gupshup': specifier: ^0.21.0 version: link:../../providers/gupshup @@ -3885,6 +3888,52 @@ importers: specifier: 4.9.5 version: 4.9.5 + providers/grafana-on-call: + dependencies: + '@novu/stateless': + specifier: 0.16.3 + version: 0.16.3 + axios: + specifier: ^1.3.3 + version: 1.6.0 + uuid: + specifier: ^9.0.0 + version: 9.0.0 + devDependencies: + '@istanbuljs/nyc-config-typescript': + specifier: ~1.0.1 + version: 1.0.2(nyc@15.1.0) + '@types/jest': + specifier: ~27.5.2 + version: 27.5.2 + cspell: + specifier: ~6.19.2 + version: 6.19.2 + jest: + specifier: ~27.5.1 + version: 27.5.1(ts-node@10.9.1) + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + nyc: + specifier: ~15.1.0 + version: 15.1.0 + prettier: + specifier: ~2.8.0 + version: 2.8.7 + rimraf: + specifier: ~3.0.2 + version: 3.0.2 + ts-jest: + specifier: ~27.1.5 + version: 27.1.5(@babel/core@7.23.2)(@types/jest@27.5.2)(jest@27.5.1)(typescript@4.9.5) + ts-node: + specifier: ~10.9.1 + version: 10.9.1(@types/node@14.18.42)(typescript@4.9.5) + typescript: + specifier: 4.9.5 + version: 4.9.5 + providers/gupshup: dependencies: '@novu/stateless': diff --git a/providers/grafana-on-call/.czrc b/providers/grafana-on-call/.czrc new file mode 100644 index 00000000000..d1bcc209ca1 --- /dev/null +++ b/providers/grafana-on-call/.czrc @@ -0,0 +1,3 @@ +{ + "path": "cz-conventional-changelog" +} diff --git a/providers/grafana-on-call/.eslintrc.json b/providers/grafana-on-call/.eslintrc.json new file mode 100644 index 00000000000..ec40100be69 --- /dev/null +++ b/providers/grafana-on-call/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.js" +} diff --git a/providers/grafana-on-call/.gitignore b/providers/grafana-on-call/.gitignore new file mode 100644 index 00000000000..963d5292865 --- /dev/null +++ b/providers/grafana-on-call/.gitignore @@ -0,0 +1,9 @@ +.idea/* +.nyc_output +build +node_modules +test +src/**.js +coverage +*.log +package-lock.json diff --git a/providers/grafana-on-call/README.md b/providers/grafana-on-call/README.md new file mode 100644 index 00000000000..d92f85768c3 --- /dev/null +++ b/providers/grafana-on-call/README.md @@ -0,0 +1,11 @@ +# Novu GrafanaOnCall Provider + +A GrafanaOnCall chat provider library for [@novu/node](https://github.com/novuhq/novu) + +## Usage + +```javascript + import { GrafanaOnCallChatProvider } from '@novu/grafana-on-call'; + + const provider = new GrafanaOnCallChatProvider({ alertUid: "123", externalLink: "link", imageUrl: "url", state: "ok", title: "title" }); +``` diff --git a/providers/grafana-on-call/jest.config.js b/providers/grafana-on-call/jest.config.js new file mode 100644 index 00000000000..ac8bee2b638 --- /dev/null +++ b/providers/grafana-on-call/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + "moduleNameMapper": { + "axios": "axios/dist/node/axios.cjs" + } +}; diff --git a/providers/grafana-on-call/package.json b/providers/grafana-on-call/package.json new file mode 100644 index 00000000000..b286f0a121c --- /dev/null +++ b/providers/grafana-on-call/package.json @@ -0,0 +1,79 @@ +{ + "name": "@novu/grafana-on-call", + "version": "0.21.0", + "description": "A grafana-on-call wrapper for novu", + "main": "build/main/index.js", + "typings": "build/main/index.d.ts", + "module": "build/module/index.js", + "private": false, + "repository": "https://github.com/novuhq/novu", + "license": "MIT", + "keywords": [], + "scripts": { + "prebuild": "rimraf build", + "build": "run-p build:*", + "build:main": "tsc -p tsconfig.json", + "build:module": "tsc -p tsconfig.module.json", + "fix": "run-s fix:*", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "fix:lint": "eslint src --ext .ts --fix", + "test": "run-s test:*", + "lint": "eslint src --ext .ts", + "test:unit": "jest src", + "watch:build": "tsc -p tsconfig.json -w", + "watch:test": "jest src --watch", + "reset-hard": "git clean -dfx && git reset --hard && yarn", + "prepare-release": "run-s reset-hard test" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@novu/stateless": "0.16.3", + "axios": "^1.3.3", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@istanbuljs/nyc-config-typescript": "~1.0.1", + "@types/jest": "~27.5.2", + "cspell": "~6.19.2", + "jest": "~27.5.1", + "npm-run-all": "^4.1.5", + "nyc": "~15.1.0", + "prettier": "~2.8.0", + "rimraf": "~3.0.2", + "ts-jest": "~27.1.5", + "ts-node": "~10.9.1", + "typescript": "4.9.5" + }, + "files": [ + "build/main", + "build/module", + "!**/*.spec.*", + "!**/*.json", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], + "ava": { + "failFast": true, + "timeout": "60s", + "typescript": { + "rewritePaths": { + "src/": "build/main/" + } + }, + "files": [ + "!build/module/**" + ] + }, + "prettier": { + "singleQuote": true + }, + "nyc": { + "extends": "@istanbuljs/nyc-config-typescript", + "exclude": [ + "**/*.spec.js" + ] + } +} diff --git a/providers/grafana-on-call/src/index.ts b/providers/grafana-on-call/src/index.ts new file mode 100644 index 00000000000..d4f06b04ae6 --- /dev/null +++ b/providers/grafana-on-call/src/index.ts @@ -0,0 +1 @@ +export * from './lib/grafana-on-call.provider'; diff --git a/providers/grafana-on-call/src/lib/grafana-on-call.provider.spec.ts b/providers/grafana-on-call/src/lib/grafana-on-call.provider.spec.ts new file mode 100644 index 00000000000..2ed2f235029 --- /dev/null +++ b/providers/grafana-on-call/src/lib/grafana-on-call.provider.spec.ts @@ -0,0 +1,42 @@ +import { GrafanaOnCallChatProvider } from './grafana-on-call.provider'; +import axios from 'axios'; + +test('should trigger grafana-on-call library correctly', async () => { + const date = new Date(); + const fakePost = jest.fn(() => { + return { headers: { ['Date']: date } }; + }); + + jest.spyOn(axios, 'create').mockImplementation(() => { + return { + post: fakePost, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }); + + const provider = new GrafanaOnCallChatProvider({ + alertUid: '123', + externalLink: 'link', + imageUrl: 'url', + state: 'ok', + title: 'title', + }); + + const testWebhookUrl = 'https://mycompany.webhook.grafana.com/'; + const testContent = 'warning!!'; + const res = await provider.sendMessage({ + webhookUrl: testWebhookUrl, + content: testContent, + }); + + expect(fakePost).toHaveBeenCalled(); + expect(fakePost).toHaveBeenCalledWith(testWebhookUrl, { + alert_uid: '123', + link_to_upstream_details: 'link', + image_url: 'url', + state: 'ok', + title: 'title', + message: testContent, + }); + expect(res).toEqual({ id: expect.any(String), date: date.toISOString() }); +}); diff --git a/providers/grafana-on-call/src/lib/grafana-on-call.provider.ts b/providers/grafana-on-call/src/lib/grafana-on-call.provider.ts new file mode 100644 index 00000000000..f21d66efcad --- /dev/null +++ b/providers/grafana-on-call/src/lib/grafana-on-call.provider.ts @@ -0,0 +1,44 @@ +import { + ChannelTypeEnum, + ISendMessageSuccessResponse, + IChatOptions, + IChatProvider, +} from '@novu/stateless'; +import axios from 'axios'; +import { v4 as uuid } from 'uuid'; + +export class GrafanaOnCallChatProvider implements IChatProvider { + id = 'grafana-on-call'; + channelType = ChannelTypeEnum.CHAT as ChannelTypeEnum.CHAT; + private axiosInstance = axios.create(); + constructor( + private config: { + alertUid?: string; + title?: string; + imageUrl?: string; + state?: string; + externalLink?: string; + } + ) {} + + async sendMessage( + options: IChatOptions + ): Promise { + const url = new URL(options.webhookUrl); + const body = { + alert_uid: this.config.alertUid, + title: this.config.title, + image_url: this.config.imageUrl, + state: this.config.state, + link_to_upstream_details: this.config.externalLink, + message: options.content, + }; + //response is just string "Ok." + const { headers } = await this.axiosInstance.post(url.toString(), body); + + return { + id: uuid(), + date: (headers.Date ? new Date(headers.Date) : new Date()).toISOString(), + }; + } +} diff --git a/providers/grafana-on-call/tsconfig.json b/providers/grafana-on-call/tsconfig.json new file mode 100644 index 00000000000..5b8120fea36 --- /dev/null +++ b/providers/grafana-on-call/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "build/main", + "rootDir": "src", + "types": ["node", "jest"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules/**"] +} diff --git a/providers/grafana-on-call/tsconfig.module.json b/providers/grafana-on-call/tsconfig.module.json new file mode 100644 index 00000000000..79be3a5c40b --- /dev/null +++ b/providers/grafana-on-call/tsconfig.module.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "target": "esnext", + "outDir": "build/module", + "module": "esnext" + }, + "exclude": ["node_modules/**"] +}