├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── app.json ├── lib ├── config │ ├── env │ │ └── test.js │ └── index.js ├── controllers │ ├── grafana.js │ └── routes.js ├── express │ └── index.js ├── logger │ └── index.js └── statuspage │ └── index.js ├── package.json ├── server.js └── test ├── .eslintrc ├── controllers └── grafana.js ├── express └── index.js ├── mocha.opts └── statuspage └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "globals": { 8 | "fetch": true, 9 | "Headers": true 10 | }, 11 | "rules": { 12 | "arrow-parens": ["error", "as-needed"], 13 | "arrow-body-style": "off", 14 | "comma-dangle": ["error", "never"], 15 | "consistent-return": "off", 16 | "default-case": "off", 17 | "func-names": "off", 18 | "generator-star-spacing": ["error", { "before": true, "after": true }], 19 | "no-confusing-arrow": "off", 20 | "no-continue": "off", 21 | "no-param-reassign": "off", 22 | "no-path-concat": "off", 23 | "no-plusplus": "off", 24 | "no-restricted-properties": "off", 25 | "no-restricted-syntax": [ 26 | "error", 27 | "ForInStatement", 28 | "LabeledStatement", 29 | "WithStatement", 30 | ], 31 | "no-return-assign": "off", 32 | "no-shadow": "off", 33 | "no-underscore-dangle": "off", 34 | "one-var": "off", 35 | "prefer-template": "off", 36 | "space-before-function-paren": ["error", "never"], 37 | "strict": "off", 38 | "import/no-dynamic-require": "off" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Conversio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grafana-statuspage 2 | Grafana-StatusPage.io connector 3 | 4 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/getconversio/grafana-statuspage) 5 | 6 | Use the above button to create a new Heroku App to connect Grafana and StatusPage. You need to specify a `STATUSPAGE_API_KEY` and `STATUSPAGE_PAGE_ID` config settings in the newly created application. 7 | 8 | ## How to add a new alert 9 | 10 | Use latest version of Grafana that enables the Alerting feature, then create a new Webhook notification. The url should follow this structure: 11 | 12 | https://yourapp.herokuapp.com/grafana/{componentId} 13 | 14 | Where `componentId` is the id in your StatusPage.io component url. 15 | 16 | By default on `alerting` webhook from Grafana, it will post a `degraded_performance` to StatusPage. You can override the status in the url: 17 | 18 | https://yourapp.herokuapp.com/grafana/{componentId}/{status} 19 | 20 | e.g. https://yourapp.herokuapp.com/grafana/dja8902jx/partial_outage 21 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grafana-statuspage", 3 | "description": "Grafana-StatusPage connector", 4 | "repository": "https://github.com/getconversio/grafana-statuspage.git", 5 | "scripts": {}, 6 | "env": { 7 | "STATUSPAGE_API_KEY": { 8 | "required": true 9 | }, 10 | "STATUSPAGE_PAGE_ID": { 11 | "required": true 12 | } 13 | }, 14 | "formation": {}, 15 | "addons": [ 16 | "logentries" 17 | ], 18 | "buildpacks": [ 19 | { 20 | "url": "heroku/nodejs" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /lib/config/env/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | log: { 5 | level: 'error' 6 | }, 7 | statuspage: { 8 | pageId: 'fooPageId', 9 | apiKey: 'fooApiKey' 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /lib/config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | 5 | const config = { 6 | port: process.env.PORT || 9002, 7 | host: process.env.HOST || '0.0.0.0', 8 | log: { 9 | level: process.env.LOG_LEVEL || 'debug' 10 | }, 11 | statuspage: { 12 | pageId: process.env.STATUSPAGE_PAGE_ID, 13 | apiKey: process.env.STATUSPAGE_API_KEY 14 | } 15 | }; 16 | 17 | const envFile = `${__dirname}/env/${process.env.NODE_ENV}.js`; 18 | 19 | if (fs.existsSync(envFile)) { 20 | /* eslint global-require: off */ 21 | Object.assign(config, require(envFile)); 22 | } 23 | 24 | module.exports = config; 25 | -------------------------------------------------------------------------------- /lib/controllers/grafana.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const logger = require('../logger'), 4 | statuspage = require('../statuspage'); 5 | 6 | const STATUS_MAPPING = { 7 | alerting: 'degraded_performance', 8 | ok: 'operational' 9 | }; 10 | 11 | const receiveWebhook = (req, res) => { 12 | let status = STATUS_MAPPING[String(req.body.state).toLowerCase()]; 13 | 14 | if (!status) { 15 | logger.warn(`Status ${req.body.state} not found in mapping.`); 16 | 17 | status = 'operational'; 18 | } 19 | 20 | if (req.params.status && req.body.state === 'alerting') { 21 | status = req.params.status; 22 | } 23 | 24 | logger.info(`Updating ${req.params.componentId} component to ${status} status.`); 25 | 26 | return statuspage.postUpdate(req.params.componentId, status) 27 | .then(() => res.sendStatus(200)) 28 | .catch(err => { 29 | logger.warn('Failed to post an update to StatusPage.io', err); 30 | 31 | res.sendStatus(200); 32 | }); 33 | }; 34 | 35 | module.exports = { 36 | receiveWebhook 37 | }; 38 | -------------------------------------------------------------------------------- /lib/controllers/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const grafana = require('./grafana'); 4 | 5 | module.exports = router => { 6 | router.post('/:componentId/:status?', grafana.receiveWebhook); 7 | return router; 8 | }; 9 | -------------------------------------------------------------------------------- /lib/express/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'), 4 | bodyParser = require('body-parser'), 5 | logger = require('../logger'), 6 | routes = require('../controllers/routes'); 7 | 8 | module.exports = () => { 9 | const app = express(); 10 | app.use(bodyParser.json({ limit: '10mb' })); 11 | app.use('/grafana', routes(new express.Router())); 12 | 13 | /* eslint no-unused-vars: off */ 14 | app.use((err, req, res, next) => { 15 | logger.error('Internal server error', err, req.query, req.params); 16 | 17 | res.status(err.status || 500); 18 | res.send('Internal server error'); 19 | }); 20 | 21 | app.get('/', (req, res) => res.sendStatus(200)); 22 | 23 | return app; 24 | }; 25 | -------------------------------------------------------------------------------- /lib/logger/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const winston = require('winston'), 4 | config = require('../config'); 5 | 6 | winston.level = config.log.level; 7 | 8 | module.exports = winston; 9 | -------------------------------------------------------------------------------- /lib/statuspage/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../config'), 4 | logger = require('../logger'); 5 | 6 | require('isomorphic-fetch'); 7 | 8 | const VALID_STATUSES = [ 9 | 'operational', 10 | 'degraded_performance', 11 | 'partial_outage', 12 | 'major_outage' 13 | ]; 14 | 15 | const postUpdate = (componentId, status) => { 16 | if (!VALID_STATUSES.includes(status)) { 17 | return Promise.reject(new Error(`${status} is not a valid status. Valid statuses are ${VALID_STATUSES.join(', ')}.`)); 18 | } 19 | 20 | return fetch(`https://api.statuspage.io/v1/pages/${config.statuspage.pageId}/components/${componentId}.json`, { 21 | method: 'PATCH', 22 | body: JSON.stringify({ component: { status } }), 23 | headers: { 24 | Authorization: 'OAuth ' + config.statuspage.apiKey 25 | } 26 | }) 27 | .then(res => { 28 | if (res.ok) return; 29 | 30 | return res.text() 31 | .then(_res => { 32 | logger.warn('Got a bad response', _res); 33 | 34 | const error = new Error(`Request to StatusPage.io failed: ${res.status} ${res.statusText}`); 35 | error.body = _res; 36 | 37 | throw error; 38 | }); 39 | }); 40 | }; 41 | 42 | module.exports = { 43 | postUpdate 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grafana-statuspage", 3 | "version": "1.0.0", 4 | "description": "Grafana-StatusPage.io connector", 5 | "main": "server.js", 6 | "scripts": { 7 | "autotest": "NODE_ENV=test node_modules/.bin/supervisor -q -n exit -x node_modules/.bin/_mocha --", 8 | "coverage": "NODE_ENV=test node_modules/.bin/istanbul cover node_modules/.bin/_mocha", 9 | "lint": "NODE_ENV=test node_modules/.bin/eslint lib test", 10 | "test": "NODE_ENV=test node_modules/.bin/mocha" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/getconversio/grafana-statuspage.git" 15 | }, 16 | "author": "Stefano Sala ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/getconversio/grafana-statuspage/issues" 20 | }, 21 | "homepage": "https://github.com/getconversio/grafana-statuspage#readme", 22 | "devDependencies": { 23 | "eslint": "^3.12.2", 24 | "eslint-config-airbnb-base": "^11.0.0", 25 | "eslint-plugin-import": "^2.2.0", 26 | "istanbul": "^0.4.5", 27 | "mocha": "^3.2.0", 28 | "nock": "^9.0.2", 29 | "sinon": "^1.17.6", 30 | "supertest": "^2.0.1", 31 | "supertest-as-promised": "^4.0.2", 32 | "supervisor": "^0.12.0" 33 | }, 34 | "dependencies": { 35 | "body-parser": "^1.15.2", 36 | "express": "^4.14.0", 37 | "isomorphic-fetch": "^2.2.1", 38 | "winston": "^2.3.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('./lib/config'), 4 | app = require('./lib/express'), 5 | logger = require('./lib/logger'); 6 | 7 | app().listen(config.port, config.host, () => { 8 | logger.info(`Server running at http://${config.host}:${config.port}`); 9 | }); 10 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "import/no-extraneous-dependencies": ["error", { devDependencies: true }], 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/controllers/grafana.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('supertest-as-promised'), 4 | app = require('../../lib/express'), 5 | statuspage = require('../../lib/statuspage'), 6 | logger = require('../../lib/logger'), 7 | sinon = require('sinon'); 8 | 9 | describe('controllers/grafana', () => { 10 | const self = { }; 11 | 12 | beforeEach(() => { 13 | self.sandbox = sinon.sandbox.create(); 14 | 15 | self.postUpdateStub = self.sandbox.stub(statuspage, 'postUpdate') 16 | .returns(Promise.resolve()); 17 | 18 | self.warnSpy = self.sandbox.spy(logger, 'warn'); 19 | }); 20 | 21 | afterEach(() => self.sandbox.restore()); 22 | 23 | describe('receiveWebhook', () => { 24 | it('should reply 200 to a correct url', () => { 25 | return request(app()) 26 | .post('/grafana/foo') 27 | .send({ 28 | state: 'Alerting' 29 | }) 30 | .expect(200) 31 | .then(() => { 32 | sinon.assert.calledOnce(self.postUpdateStub); 33 | sinon.assert.calledWith(self.postUpdateStub, 'foo', 'degraded_performance'); 34 | }); 35 | }); 36 | 37 | it('should post an update for OK status', () => { 38 | return request(app()) 39 | .post('/grafana/foo') 40 | .send({ 41 | state: 'OK' 42 | }) 43 | .expect(200) 44 | .then(() => { 45 | sinon.assert.calledOnce(self.postUpdateStub); 46 | sinon.assert.calledWith(self.postUpdateStub, 'foo', 'operational'); 47 | }); 48 | }); 49 | 50 | it('should have a default status for missing mapping', () => { 51 | return request(app()) 52 | .post('/grafana/foo') 53 | .send({ 54 | state: 'Foo' 55 | }) 56 | .expect(200) 57 | .then(() => { 58 | sinon.assert.calledOnce(self.postUpdateStub); 59 | sinon.assert.calledWith(self.postUpdateStub, 'foo', 'operational'); 60 | }); 61 | }); 62 | 63 | it('should handle lowercase statuses', () => { 64 | return request(app()) 65 | .post('/grafana/foo') 66 | .send({ 67 | state: 'alerting' 68 | }) 69 | .expect(200) 70 | .then(() => { 71 | sinon.assert.calledOnce(self.postUpdateStub); 72 | sinon.assert.calledWith(self.postUpdateStub, 'foo', 'degraded_performance'); 73 | }); 74 | }); 75 | 76 | it('should accept a custom status', () => { 77 | return request(app()) 78 | .post('/grafana/foo/partial_outage') 79 | .send({ 80 | state: 'alerting' 81 | }) 82 | .expect(200) 83 | .then(() => { 84 | sinon.assert.calledOnce(self.postUpdateStub); 85 | sinon.assert.calledWith(self.postUpdateStub, 'foo', 'partial_outage'); 86 | }); 87 | }); 88 | 89 | context('given an error from postUpdate', () => { 90 | beforeEach(() => { 91 | self.postUpdateStub.returns(Promise.reject(new Error('401 Not authorized'))); 92 | }); 93 | 94 | it('should still reply a 200', () => { 95 | return request(app()) 96 | .post('/grafana/foo') 97 | .send({ 98 | state: 'Alerting' 99 | }) 100 | .expect(200) 101 | .then(() => { 102 | sinon.assert.calledOnce(self.warnSpy); 103 | }); 104 | }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/express/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('supertest-as-promised'), 4 | app = require('../../lib/express'); 5 | 6 | describe('express/index', () => { 7 | it('should handle /', () => { 8 | return request(app()) 9 | .get('/') 10 | .expect(200); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --recursive 3 | -------------------------------------------------------------------------------- /test/statuspage/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const statuspage = require('../../lib/statuspage'), 4 | nock = require('nock'), 5 | assert = require('assert'); 6 | 7 | describe('statuspage/index', () => { 8 | const self = { }; 9 | 10 | afterEach(() => nock.cleanAll()); 11 | 12 | describe('postUpdate', () => { 13 | beforeEach(() => { 14 | self.updateCall = nock('https://api.statuspage.io', { reqheaders: { authorization: 'OAuth fooApiKey' } }) 15 | .patch('/v1/pages/fooPageId/components/ftgks51sfs2d.json', { 16 | component: { 17 | status: 'degraded_performance' 18 | } 19 | }) 20 | .reply(200, { 21 | created_at: '2013-03-05T20:50:42Z', 22 | id: 'ftgks51sfs2d', 23 | name: 'API', 24 | description: 'Lorem', 25 | position: 1, 26 | status: 'degraded_performance', 27 | updated_at: '2013-03-05T22:44:21Z' 28 | }); 29 | }); 30 | 31 | it('should post an update to statuspage.io', () => { 32 | return statuspage.postUpdate('ftgks51sfs2d', 'degraded_performance') 33 | .then(() => assert.equal(true, self.updateCall.isDone())); 34 | }); 35 | 36 | it('should throw an error with an invalid status', () => { 37 | return statuspage.postUpdate('ftgks51sfs2d', 'foo') 38 | .then(() => Promise.reject(new Error('You shall not pass!'))) 39 | .catch(err => { 40 | assert.equal(false, self.updateCall.isDone()); 41 | assert(err.message.includes('foo is not a valid status')); 42 | }); 43 | }); 44 | 45 | context('given a failure on statuspage call', () => { 46 | beforeEach(() => { 47 | nock.cleanAll(); 48 | 49 | self.updateCall = nock('https://api.statuspage.io') 50 | .patch('/v1/pages/fooPageId/components/ftgks51sfs2d.json') 51 | .reply(503); 52 | }); 53 | 54 | it('should throw an error', () => { 55 | return statuspage.postUpdate('ftgks51sfs2d', 'degraded_performance') 56 | .then(() => Promise.reject(new Error('You shall not pass!'))) 57 | .catch(err => { 58 | assert.equal(true, self.updateCall.isDone()); 59 | assert(err.message.includes(503)); 60 | }); 61 | }); 62 | }); 63 | }); 64 | }); 65 | --------------------------------------------------------------------------------