diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index feab8ba..7e90043 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,5 +18,13 @@ "mounts": [ "source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume" ], - "postCreateCommand": "sudo chown node node_modules" + "postCreateCommand": "sudo chown node node_modules", + "customizations": { + "vscode": { + "extensions": [ + "esbenp.prettier-vscode", + "GitHub.copilot" + ] + } + } } diff --git a/.husky/pre-commit b/.husky/pre-commit index 449fcde..802822a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm test +npm test -- --forbid-only --forbid-pending diff --git a/.husky/pre-push b/.husky/pre-push index 449fcde..802822a 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm test +npm test -- --forbid-only --forbid-pending diff --git a/package-lock.json b/package-lock.json index 61ca21d..549be1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hubot-grafana", - "version": "5.1.2", + "version": "6.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hubot-grafana", - "version": "5.1.2", + "version": "6.0.0", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.378.0", @@ -7047,8 +7047,9 @@ } }, "node_modules/tslib": { - "version": "2.5.0", - "license": "0BSD" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/type-detect": { "version": "4.0.8", diff --git a/package.json b/package.json index f79c6d1..ec42f08 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hubot-grafana", "description": "Query Grafana dashboards", - "version": "5.1.2", + "version": "6.0.0", "author": "Stephen Yeargin ", "license": "MIT", "keywords": [ @@ -39,7 +39,7 @@ }, "main": "index.js", "scripts": { - "test": "mocha \"test/**/*.js\" --reporter spec", + "test": "mocha \"test/**/*.js\" --reporter spec --no-experiemental-fetch --timeout 200", "test-with-coverage": "nyc --reporter=text mocha \"test/**/*.js\" --reporter spec", "bootstrap": "script/bootstrap", "prepare": "husky install", @@ -49,7 +49,8 @@ "src/**/*.js", "CONTRIBUTING.md", "LICENSE", - "index.js" + "index.js", + "types.d.ts" ], "volta": { "node": "18.19.0" diff --git a/src/Bot.js b/src/Bot.js new file mode 100644 index 0000000..f764df2 --- /dev/null +++ b/src/Bot.js @@ -0,0 +1,101 @@ +const { Adapter } = require('./adapters/Adapter'); +const { GrafanaService } = require('./service/GrafanaService'); +const { GrafanaClient } = require('./grafana-client'); + +/** + * The bot brings the Adapter and the Grafana Service together. + * It can be used for uploading charts and sending responses out. + */ +class Bot { + /** + * Represents the Bot class. + * @constructor + * @param {Hubot.Robot} robot - The robot instance. + */ + constructor(robot) { + /** @type {Adapter} */ + this.adapter = new Adapter(robot); + + /** @type {Hubot.Log} */ + this.logger = robot.logger; + } + + /** + * Creates a new Grafana service based on the provided message. + * @param {Hubot.Response} context - The context object. + * @returns {GrafanaService|null} - The created Grafana service or null if the client is not available. + */ + createService(context) { + + const robot = context.robot; + let host = process.env.HUBOT_GRAFANA_HOST; + let apiKey = process.env.HUBOT_GRAFANA_API_KEY; + + if (process.env.HUBOT_GRAFANA_PER_ROOM === '1') { + const room = this.getRoom(context); + host = robot.brain.get(`grafana_host_${room}`); + apiKey = robot.brain.get(`grafana_api_key_${room}`); + } + + if (host == null) { + this.sendError('No Grafana endpoint configured.', context); + return null; + } + + let client = new GrafanaClient(robot.http, robot.logger, host, apiKey); + return new GrafanaService(client); + } + + /** + * Sends a dashboard chart. + * + * @param {Hubot.Response} context - The context object. + * @param {DashboardChart} dashboard - The dashboard object. + * @returns {Promise} - A promise that resolves when the chart is sent. + */ + async sendDashboardChart(context, dashboard) { + if (!this.adapter.isUploadSupported()) { + this.adapter.responder.send(context, dashboard.title, dashboard.imageUrl, dashboard.grafanaChartLink); + return; + } + + const service = this.createService(context); + if (service == null) return; + + /** @type {DownloadedFile|null} */ + let file = null; + + try { + file = await service.client.download(dashboard.imageUrl); + } catch (err) { + this.sendError(err, context); + return; + } + + this.logger.debug(`Uploading file: ${file.body.length} bytes, content-type[${file.contentType}]`); + this.adapter.uploader.upload(context, dashboard.title || 'Image', file, dashboard.grafanaChartLink); + } + + /** + * *Sends an error message. + * @param {string} message the error message. + * @param {Hubot.Response} context The context. + */ + sendError(message, context) { + context.robot.logger.error(message); + context.send(message); + } + + /** + * Gets the room from the context. + * @param {Hubot.Response} context The context. + * @returns {string} + */ + getRoom(context) { + // placeholder for further adapter support (i.e. MS Teams) as then room also + // contains thread conversation id + return context.envelope.room; + } +} + +exports.Bot = Bot; diff --git a/src/adapters/Adapter.js b/src/adapters/Adapter.js index 1dfdc1c..fa19fa1 100644 --- a/src/adapters/Adapter.js +++ b/src/adapters/Adapter.js @@ -11,6 +11,13 @@ const { RocketChatUploader } = require('./implementations/RocketChatUploader'); const { TelegramUploader } = require('./implementations/TelegramUploader'); const { SlackUploader } = require('./implementations/SlackUploader'); +/** + * The override responder is used to override the default responder. + * This can be used to inject a custom responder to influence the message formatting. + * @type {Responder|null} + */ +let overrideResponder = null; + /** * The Adapter will hide away platform specific details for file upload and * response messages. When an S3 bucket is configured, it will always take @@ -53,6 +60,11 @@ class Adapter { */ /** @type {Responder} */ get responder() { + + if(overrideResponder){ + return overrideResponder; + } + if (/slack/i.test(this.robot.adapterName)) { return new SlackResponder(); } @@ -66,7 +78,7 @@ class Adapter { return new Responder(); } - /** + /** * The responder is responsible for doing a (platform specific) upload. * If an upload is not supported, the method will throw an error. */ @@ -80,7 +92,7 @@ class Adapter { case 'slack': return new SlackUploader(this.robot, this.robot.logger); case 'telegram': - return new TelegramUploader() + return new TelegramUploader(); } throw new Error(`Upload not supported for '${this.robot.adapterName}'`); @@ -95,4 +107,19 @@ class Adapter { } } +/** + * Overrides the responder. + * @param {Responder} responder The responder to use. + */ +exports.setResponder = function(responder) { + overrideResponder = responder; +} + +/** + * Clears the override responder. + */ +exports.clearResponder = function() { + overrideResponder = null; +} + exports.Adapter = Adapter; diff --git a/src/grafana-client.js b/src/grafana-client.js index 208df13..ef3d50d 100644 --- a/src/grafana-client.js +++ b/src/grafana-client.js @@ -1,215 +1,215 @@ -'strict'; -const fetch = require('node-fetch'); -const { URL, URLSearchParams } = require('url'); - -class GrafanaClient { - /** - * Creates a new instance. - * @param {(url: string, options?: HttpOptions)=>ScopedClient} http the HTTP client. - * @param {Hubot.Log} res the logger. - * @param {string} grafana_host the host. - * @param {string} grafana_api_key the api key. - */ - constructor(http, logger, grafana_host, grafana_api_key) { - /** - * The HTTP client - * @type {(url: string, options?: HttpOptions)=>ScopedClient} - */ - this.http = http; - - /** - * The logger. - * @type {Hubot.Log} - */ - this.logger = logger; - - /** - * The host. - * @type {string | null} - */ - this.grafana_host = grafana_host; - - /** - * The API key. - * @type {string | null} - */ - this.grafana_api_key = grafana_api_key; - } - - /** - * Creates a scoped HTTP client. - * @param {string} url The URL. - * @param {string | null} contentType Indicates if the HTTP client should post. - * @param {encoding | false} encoding Indicates if an encoding should be set. - * @returns {ScopedClient} - */ - createHttpClient(url, contentType = null, encoding = false) { - if (!url.startsWith('http://') && !url.startsWith('https://') && !this.grafana_host) { - throw new Error('No Grafana endpoint configured.'); - } - - // in case of a download we get a "full" URL - const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${this.grafana_host}/api/${url}`; - const headers = grafanaHeaders(contentType, encoding, this.grafana_api_key); - const client = this.http(fullUrl).headers(headers); - - return client; - } - - /** - * Performs a GET on the Grafana API. - * Remarks: uses Hubot because of Nock testing. - * @param {string} url the url - * @returns {Promise} - */ - async get(url) { - let client = this.createHttpClient(url); - return new Promise((resolve) => { - client.get()((err, res, body) => { - if (err) { - throw err; - } - const data = JSON.parse(body); - return resolve(data); - }); - }); - } - - /** - * Performs a POST call to the Grafana API. - * - * @param {string} url The API sub URL - * @param {Record} data The data that will be sent. - * @returns {Promise} - */ - post(url, data) { - const http = this.createHttpClient(url, 'application/json'); - const jsonPayload = JSON.stringify(data); - - return new Promise((resolve, reject) => { - http.post(jsonPayload)((err, res, body) => { - if (err) { - reject(err); - return; - } - - data = JSON.parse(body); - resolve(data); - }); - }); - } - - /** - * Downloads the given URL. - * @param {string} url The URL. - * @returns {Promise<{ body: Buffer, contentType: string}>} - */ - async download(url) { - return await fetch(url, { - method: 'GET', - headers: grafanaHeaders(null, null, this.grafana_api_key), - }).then(async (res) => { - const contentType = res.headers.get('content-type'); - const body = await res.arrayBuffer(); - - return { - body: Buffer.from(body), - contentType: contentType, - }; - }); - } - - createGrafanaChartLink(query, uid, panel, timespan, variables) { - const url = new URL(`${this.grafana_host}/d/${uid}/`); - - if (panel) { - url.searchParams.set('panelId', panel.id); - url.searchParams.set('fullscreen', ''); - } - - url.searchParams.set('from', timespan.from); - url.searchParams.set('to', timespan.to); - - if (variables) { - const additionalParams = new URLSearchParams(variables); - for (const [key, value] of additionalParams) { - url.searchParams.append(key, value); - } - } - - // TODO: should we add these? - // if (query.tz) { - // url.searchParams.set('tz', query.tz); - // } - // if (query.orgId) { - // url.searchParams.set('orgId', query.orgId); - // } - - return url.toString().replace('fullscreen=&', 'fullscreen&'); - } - - createImageUrl(query, uid, panel, timespan, variables) { - const url = new URL(`${this.grafana_host}/render/${query.apiEndpoint}/${uid}/`); - - if (panel) { - url.searchParams.set('panelId', panel.id); - } else if (query.kiosk) { - url.searchParams.set('kiosk', ''); - url.searchParams.set('autofitpanels', ''); - } - - url.searchParams.set('width', query.width); - url.searchParams.set('height', query.height); - url.searchParams.set('from', timespan.from); - url.searchParams.set('to', timespan.to); - - if (variables) { - const additionalParams = new URLSearchParams(variables); - for (const [key, value] of additionalParams) { - url.searchParams.append(key, value); - } - } - - if (query.tz) { - url.searchParams.set('tz', query.tz); - } - - //TODO: currently not tested - if (query.orgId) { - url.searchParams.set('orgId', query.orgId); - } - - return url.toString().replace('kiosk=&', 'kiosk&').replace('autofitpanels=&', 'autofitpanels&'); - } -} - -/** - * Create headers for the Grafana request. - * @param {string | null} contentType Indicates if the HTTP client should post. - * @param {string | false} encoding Indicates if an encoding should be set. - * @param {string | null} api_key The API key. - * @returns {Record} - */ -function grafanaHeaders(contentType, encoding, api_key) { - const headers = { Accept: 'application/json' }; - - if (contentType) { - headers['Content-Type'] = contentType; - } - - // download needs a null encoding - // TODO: are we sure? - if (encoding !== false) { - headers['encoding'] = encoding; - } - - if (api_key) { - headers.Authorization = `Bearer ${api_key}`; - } - - return headers; -} - -module.exports = { - GrafanaClient, -}; +'strict'; +const fetch = require('node-fetch'); +const { URL, URLSearchParams } = require('url'); + +/// + +class GrafanaClient { + /** + * Creates a new instance. + * @param {(url: string, options?: HttpOptions)=>ScopedClient} http the HTTP client. + * @param {Hubot.Log} logger the logger. + * @param {string} host the host. + * @param {string} apiKey the api key. + */ + constructor(http, logger, host, apiKey) { + /** + * The HTTP client + * @type {(url: string, options?: HttpOptions)=>ScopedClient} + */ + this.http = http; + + /** + * The logger. + * @type {Hubot.Log} + */ + this.logger = logger; + + /** + * The host. + * @type {string | null} + */ + this.host = host; + + /** + * The API key. + * @type {string | null} + */ + this.apiKey = apiKey; + } + + /** + * Creates a scoped HTTP client. + * @param {string} url The URL. + * @param {string | null} contentType Indicates if the HTTP client should post. + * @param {encoding | false} encoding Indicates if an encoding should be set. + * @returns {ScopedClient} + */ + createHttpClient(url, contentType = null, encoding = false) { + if (!url.startsWith('http://') && !url.startsWith('https://') && !this.host) { + throw new Error('No Grafana endpoint configured.'); + } + + // in case of a download we get a "full" URL + const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${this.host}/api/${url}`; + const headers = grafanaHeaders(contentType, encoding, this.apiKey); + const client = this.http(fullUrl).headers(headers); + + return client; + } + + /** + * Performs a GET on the Grafana API. + * Remarks: uses Hubot because of Nock testing. + * @param {string} url the url + * @returns {Promise} + */ + async get(url) { + let client = this.createHttpClient(url); + return new Promise((resolve) => { + client.get()((err, res, body) => { + if (err) { + throw err; + } + const data = JSON.parse(body); + return resolve(data); + }); + }); + } + + /** + * Performs a POST call to the Grafana API. + * + * @param {string} url The API sub URL + * @param {Record} data The data that will be sent. + * @returns {Promise} + */ + post(url, data) { + const http = this.createHttpClient(url, 'application/json'); + const jsonPayload = JSON.stringify(data); + + return new Promise((resolve, reject) => { + http.post(jsonPayload)((err, res, body) => { + if (err) { + reject(err); + return; + } + + data = JSON.parse(body); + resolve(data); + }); + }); + } + + /** + * Downloads the given URL. + * @param {string} url The URL. + * @returns {Promise} + */ + async download(url) { + return await fetch(url, { + method: 'GET', + headers: grafanaHeaders(null, null, this.apiKey), + }).then(async (res) => { + const contentType = res.headers.get('content-type'); + const body = await res.arrayBuffer(); + + return { + body: Buffer.from(body), + contentType: contentType, + }; + }); + } + + createGrafanaChartLink(query, uid, panel, timespan, variables) { + const url = new URL(`${this.host}/d/${uid}/`); + + if (panel) { + url.searchParams.set('panelId', panel.id); + url.searchParams.set('fullscreen', ''); + } + + url.searchParams.set('from', timespan.from); + url.searchParams.set('to', timespan.to); + + if (variables) { + const additionalParams = new URLSearchParams(variables); + for (const [key, value] of additionalParams) { + url.searchParams.append(key, value); + } + } + + // TODO: should we add these? + // if (query.tz) { + // url.searchParams.set('tz', query.tz); + // } + // if (query.orgId) { + // url.searchParams.set('orgId', query.orgId); + // } + + return url.toString().replace('fullscreen=&', 'fullscreen&'); + } + + createImageUrl(query, uid, panel, timespan, variables) { + const url = new URL(`${this.host}/render/${query.apiEndpoint}/${uid}/`); + + if (panel) { + url.searchParams.set('panelId', panel.id); + } else if (query.kiosk) { + url.searchParams.set('kiosk', ''); + url.searchParams.set('autofitpanels', ''); + } + + url.searchParams.set('width', query.width); + url.searchParams.set('height', query.height); + url.searchParams.set('from', timespan.from); + url.searchParams.set('to', timespan.to); + + if (variables) { + const additionalParams = new URLSearchParams(variables); + for (const [key, value] of additionalParams) { + url.searchParams.append(key, value); + } + } + + if (query.tz) { + url.searchParams.set('tz', query.tz); + } + + //TODO: currently not tested + if (query.orgId) { + url.searchParams.set('orgId', query.orgId); + } + + return url.toString().replace('kiosk=&', 'kiosk&').replace('autofitpanels=&', 'autofitpanels&'); + } +} + +/** + * Create headers for the Grafana request. + * @param {string | null} contentType Indicates if the HTTP client should post. + * @param {string | false} encoding Indicates if an encoding should be set. + * @param {string | null} api_key The API key. + * @returns {Record} + */ +function grafanaHeaders(contentType, encoding, api_key) { + const headers = { Accept: 'application/json' }; + + if (contentType) { + headers['Content-Type'] = contentType; + } + + // download needs a null encoding + // TODO: are we sure? + if (encoding !== false) { + headers['encoding'] = encoding; + } + + if (api_key) { + headers.Authorization = `Bearer ${api_key}`; + } + + return headers; +} + +exports.GrafanaClient = GrafanaClient; diff --git a/src/grafana.js b/src/grafana.js index f60ec3d..49862a7 100644 --- a/src/grafana.js +++ b/src/grafana.js @@ -47,8 +47,9 @@ // hubot graf unpause all alerts - Un-pause all alerts (admin permissions required) // -const { GrafanaClient } = require('./grafana-client'); -const { Adapter } = require('./adapters/Adapter'); +/// + +const { Bot } = require('./Bot'); /** * Adds the Grafana commands to Hubot. @@ -56,420 +57,167 @@ const { Adapter } = require('./adapters/Adapter'); */ module.exports = (robot) => { // Various configuration options stored in environment variables - const grafana_host = process.env.HUBOT_GRAFANA_HOST; - const grafana_api_key = process.env.HUBOT_GRAFANA_API_KEY; - const grafana_per_room = process.env.HUBOT_GRAFANA_PER_ROOM; - const grafana_query_time_range = process.env.HUBOT_GRAFANA_QUERY_TIME_RANGE || '6h'; - const max_return_dashboards = process.env.HUBOT_GRAFANA_MAX_RETURNED_DASHBOARDS || 25; - - const adapter = new Adapter(robot); + const grafanaPerRoom = process.env.HUBOT_GRAFANA_PER_ROOM; + const maxReturnDashboards = process.env.HUBOT_GRAFANA_MAX_RETURNED_DASHBOARDS || 25; + const bot = new Bot(robot); // Set Grafana host/api_key robot.respond(/(?:grafana|graph|graf) set (host|api_key) (.+)/i, (msg) => { - if (grafana_per_room === '1') { - const context = msg.message.user.room.split('@')[0]; - robot.brain.set(`grafana_${msg.match[1]}_${context}`, msg.match[2]); - return msg.send(`Value set for ${msg.match[1]}`); + if (grafanaPerRoom !== '1') { + return bot.sendError('Set HUBOT_GRAFANA_PER_ROOM=1 to use multiple configurations.', msg); } - return sendError('Set HUBOT_GRAFANA_PER_ROOM=1 to use multiple configurations.', msg); + + const context = msg.message.user.room.split('@')[0]; + robot.brain.set(`grafana_${msg.match[1]}_${context}`, msg.match[2]); + return msg.send(`Value set for ${msg.match[1]}`); }); // Get a specific dashboard with options - robot.respond(/(?:grafana|graph|graf) (?:dash|dashboard|db) ([A-Za-z0-9\-\:_]+)(.*)?/i, (msg) => { - const grafana = createGrafanaClient(msg); - if (!grafana) return; - - let uid = msg.match[1].trim(); - const remainder = msg.match[2]; - const timespan = { - from: `now-${grafana_query_time_range}`, - to: 'now', - }; - let variables = ''; - const template_params = []; - let visualPanelId = false; - let apiPanelId = false; - let pname = false; - const query = { - width: parseInt(process.env.HUBOT_GRAFANA_DEFAULT_WIDTH, 10) || 1000, - height: parseInt(process.env.HUBOT_GRAFANA_DEFAULT_HEIGHT, 10) || 500, - tz: process.env.HUBOT_GRAFANA_DEFAULT_TIME_ZONE || '', - orgId: process.env.HUBOT_GRAFANA_ORG_ID || '', - apiEndpoint: process.env.HUBOT_GRAFANA_API_ENDPOINT || 'd-solo', - kiosk: false, - }; - - // Parse out a specific panel - if (/\:/.test(uid)) { - let parts = uid.split(':'); - uid = parts[0]; - visualPanelId = parseInt(parts[1], 10); - if (isNaN(visualPanelId)) { - visualPanelId = false; - pname = parts[1].toLowerCase(); - } - if (/panel-[0-9]+/.test(pname)) { - parts = pname.split('panel-'); - apiPanelId = parseInt(parts[1], 10); - pname = false; - } - } + robot.respond(/(?:grafana|graph|graf) (?:dash|dashboard|db) ([A-Za-z0-9\-\:_]+)(.*)?/i, async (msg) => { + const service = bot.createService(msg); + if (!service) return; - // Check if we have any extra fields - if (remainder && remainder.trim() !== '') { - // The order we apply non-variables in - const timeFields = ['from', 'to']; - - for (const part of Array.from(remainder.trim().split(' '))) { - // Check if it's a variable or part of the timespan - - if (part.indexOf('=') >= 0) { - // put query stuff into its own dict - const [partName, partValue] = part.split('=') - - if (partName in query) { - query[partName] = partValue; - continue; - } - else if (partName == "from") { - timespan.from = partValue; - continue; - } - else if (partName == "to") { - timespan.to = partValue; - continue; - } - - variables = `${variables}&var-${part}`; - template_params.push({ - name: partName, - value: partValue, - }); - } else if (part == 'kiosk') { - query.kiosk = true; - } - // Only add to the timespan if we haven't already filled out from and to - else if (timeFields.length > 0) { - timespan[timeFields.shift()] = part.trim(); - } - } + let str = msg.match[1].trim(); + if (msg.match[2]) { + str += ' ' + msg.match[2].trim(); } - robot.logger.debug(msg.match); - robot.logger.debug(uid); - robot.logger.debug(timespan); - robot.logger.debug(variables); - robot.logger.debug(template_params); - robot.logger.debug(visualPanelId); - robot.logger.debug(apiPanelId); - robot.logger.debug(pname); - - // Call the API to get information about this dashboard - return grafana.get(`dashboards/uid/${uid}`).then((dashboard) => { - robot.logger.debug(dashboard); - - // Check dashboard information - if (!dashboard) { - sendError('An error ocurred. Check your logs for more details.', msg); - return; - } - - if (dashboard.message) { - // Search for URL slug to offer help - if (dashboard.message == 'Dashboard not found') { - grafana.get('search?type=dash-db').then((results) => { - for (const item of Array.from(results)) { - if (item.url.match(new RegExp(`\/d\/[a-z0-9\-]+\/${uid}$`, 'i'))) { - sendError(`Try your query again with \`${item.uid}\` instead of \`${uid}\``, msg); - return; - } - } - return sendError(dashboard.message, msg); - }); - } else { - sendError(dashboard.message, msg); - } - return; - } - - // Defaults - const data = dashboard.dashboard; + const req = service.parseToGrafanaDashboardRequest(str); + const dashboard = await service.getDashboard(req.uid); - // Handle refactor done for version 5.0.0+ - if (dashboard.dashboard.panels) { - // Concept of "rows" was replaced by coordinate system - data.rows = [dashboard.dashboard]; - } - - // Handle empty dashboard - if (data.rows == null) { - return sendError('Dashboard empty.', msg); - } - - // Support for templated dashboards - let template_map; - robot.logger.debug(data.templating.list); - if (data.templating.list) { - template_map = []; - for (const template of Array.from(data.templating.list)) { - robot.logger.debug(template); - if (!template.current) { - continue; - } - for (const _param of Array.from(template_params)) { - if (template.name === _param.name) { - template_map[`$${template.name}`] = _param.value; - } else { - template_map[`$${template.name}`] = template.current.text; - } - } - } - } + // Check dashboard information + if (!dashboard) { + return bot.sendError('An error ocurred. Check your logs for more details.', msg); + } + if (dashboard.message) { + return bot.sendError(dashboard.message, msg); + } - if (query.kiosk) { - query.apiEndpoint = 'd'; - const imageUrl = grafana.createImageUrl(query, uid, null, timespan, variables); - const grafanaChartLink = grafana.createGrafanaChartLink(query, uid, null, timespan, variables); - const title = dashboard.dashboard.title; - sendDashboardChart(msg, title, imageUrl, grafanaChartLink); - return; - } + // Defaults + const data = dashboard.dashboard; - // Return dashboard rows - let panelNumber = 0; - let returnedCount = 0; - for (const row of Array.from(data.rows)) { - for (const panel of Array.from(row.panels)) { - robot.logger.debug(panel); - - panelNumber += 1; - // Skip if visual panel ID was specified and didn't match - if (visualPanelId && visualPanelId !== panelNumber) { - continue; - } - - // Skip if API panel ID was specified and didn't match - if (apiPanelId && apiPanelId !== panel.id) { - continue; - } - - // Skip if panel name was specified any didn't match - if (pname && panel.title.toLowerCase().indexOf(pname) === -1) { - continue; - } - - // Skip if we have already returned max count of dashboards - if (returnedCount > max_return_dashboards) { - continue; - } - - // Build links for message sending - const title = formatTitleWithTemplate(panel.title, template_map); - const { uid } = dashboard.dashboard; - const imageUrl = grafana.createImageUrl(query, uid, panel, timespan, variables); - const grafanaChartLink = grafana.createGrafanaChartLink(query, uid, panel, timespan, variables); - - sendDashboardChart(msg, title, imageUrl, grafanaChartLink); - returnedCount += 1; - } - } + // Handle empty dashboard + if (data.rows == null) { + return bot.sendError('Dashboard empty.', msg); + } - if (returnedCount === 0) { - return sendError('Could not locate desired panel.', msg); - } - }); - }); + const dashboards = await service.getDashboardCharts(req, dashboard, maxReturnDashboards); + if (dashboards == null || dashboards.length === 0) { + return bot.sendError('Could not locate desired panel.', msg); + } - // Process the bot response - const sendDashboardChart = (res, title, imageUrl, grafanaChartLink) => { - if (adapter.isUploadSupported()) { - uploadChart(res, title, imageUrl, grafanaChartLink); - } else { - adapter.responder.send(res, title, imageUrl, grafanaChartLink); + for (let d of dashboards) { + await bot.sendDashboardChart(msg, d); } - }; + }); // Get a list of available dashboards - robot.respond(/(?:grafana|graph|graf) list\s?(.+)?/i, (msg) => { - const grafana = createGrafanaClient(msg); - if (!grafana) return; + robot.respond(/(?:grafana|graph|graf) list\s?(.+)?/i, async (msg) => { + const service = bot.createService(msg); + if (!service) return; - let url = 'search?type=dash-db'; let title = 'Available dashboards:\n'; + let tag = null; if (msg.match[1]) { - const tag = msg.match[1].trim(); - url += `&tag=${tag}`; + tag = msg.match[1].trim(); title = `Dashboards tagged \`${tag}\`:\n`; } - return grafana - .get(url) - .then((dashboards) => { - robot.logger.debug(dashboards); - return sendDashboardList(dashboards, title, msg); - }) - .catch((err) => { - robot.logger.error(err, 'Error while listing dashboards, url: ' + url); - }); + const dashboards = await service.search(null, tag); + if (dashboards == null) return; + sendDashboardList(dashboards, title, msg); }); - /** - * Creates a Grafana client based on the context. If it can't create one - * it will echo an error to the user. - * @param {Hubot.Response} res the context. - * @returns {GrafanaClient | null} - */ - function createGrafanaClient(res) { - let api_key = grafana_api_key; - let host = grafana_host; - - if (grafana_per_room === '1') { - const room = get_room(res); - host = robot.brain.get(`grafana_host_${room}`); - api_key = robot.brain.get(`grafana_api_key_${room}`); - } - - if (host == null) { - sendError('No Grafana endpoint configured.', res); - return null; - } - - let grafana = new GrafanaClient(robot.http, robot.logger, host, api_key); - - return grafana; - } - // Search dashboards - robot.respond(/(?:grafana|graph|graf) search (.+)/i, (msg) => { - const grafana = createGrafanaClient(msg); - if (!grafana) return; + robot.respond(/(?:grafana|graph|graf) search (.+)/i, async (msg) => { + const service = bot.createService(msg); + if (!service) return; const query = msg.match[1].trim(); robot.logger.debug(query); - return grafana - .get(`search?type=dash-db&query=${query}`) - .then((dashboards) => { - const title = `Dashboards matching \`${query}\`:\n`; - sendDashboardList(dashboards, title, msg); - }) - .catch((err) => this.robot.logger.error(err, 'Error searching for dashboard.')); + const dashboards = await service.search(query); + if (dashboards == null) return; + + const title = `Dashboards matching \`${query}\`:\n`; + sendDashboardList(dashboards, title, msg); }); // Show alerts robot.respond(/(?:grafana|graph|graf) alerts\s?(.+)?/i, async (msg) => { - const grafana = createGrafanaClient(msg); - if (!grafana) return; + const service = bot.createService(msg); + if (!service) return; - let url = 'alerts'; let title = 'All alerts:\n'; + let state = null; // all alerts of a specific type if (msg.match[1]) { - const state = msg.match[1].trim(); - url = `alerts?state=${state}`; + state = msg.match[1].trim(); title = `Alerts with state '${state}':\n`; } robot.logger.debug(title.trim()); - await grafana - .get(url) - .then((alerts) => { - robot.logger.debug(alerts); - sendAlerts(alerts, title, msg); - }) - .catch((err) => { - robot.logger.error(err, 'Error while getting alerts on URL: ' + url); - }); + let alerts = await service.queryAlerts(state); + if (alerts == null) return; + + robot.logger.debug(alerts); + + let text = title; + + for (const alert of alerts) { + let line = `- *${alert.name}* (${alert.id}): \`${alert.state}\``; + if (alert.newStateDate) { + line += `\n last state change: ${alert.newStateDate}`; + } + if (alert.executionError) { + line += `\n execution error: ${alert.executionError}`; + } + text += line + `\n`; + } + msg.send(text.trim()); }); // Pause/unpause an alert robot.respond(/(?:grafana|graph|graf) (unpause|pause)\salert\s(\d+)/i, (msg) => { - const grafana = createGrafanaClient(msg); - if (!grafana) return; + const service = bot.createService(msg); + if (!service) return; const paused = msg.match[1] === 'pause'; const alertId = msg.match[2]; - const url = `alerts/${alertId}/pause`; - - return grafana - .post(url, { paused }) - .then((result) => { - robot.logger.debug(result); - if (result.message) { - msg.send(result.message); - } - }) - .catch((err) => { - robot.logger.error(err, 'Error for URL: ' + url); - }); + + const message = service.pauseSingleAlert(alertId, paused); + + if (message) { + msg.send(message); + } }); // Pause/unpause all alerts // requires an API token with admin permissions robot.respond(/(?:grafana|graph|graf) (unpause|pause) all(?:\s+alerts)?/i, async (msg) => { - const grafana = createGrafanaClient(msg); - if (!grafana) return; + const service = bot.createService(msg); + if (!service) return; - const paused = msg.match[1] === 'pause'; + const command = msg.match[1]; + const paused = command === 'pause'; + const result = await service.pauseAllAlerts(paused); - const alerts = await grafana.get('alerts'); - if (alerts == null || alerts.length == 0) { - return; - } - - let errored = 0; - for (const alert of Array.from(alerts)) { - const url = `alerts/${alert.id}/pause`; - try { - await grafana.post(url, { paused }); - } catch (err) { - robot.logger.error(err, 'Error for URL: ' + url); - errored++; - } - } + if (result.total == 0) return; msg.send( - `Successfully tried to ${msg.match[1]} *${alerts.length}* alerts.\n*Success: ${alerts.length - errored - }*\n*Errored: ${errored}*` + `Successfully tried to ${command} *${result.total}* alerts.\n*Success: ${result.success}*\n*Errored: ${result.errored}*` ); }); - // Send a list of alerts - - /** - * - * @param {any[]} alerts list of alerts - * @param {string} title the title - * @param {Hubot.Response} res the context - * @returns - */ - const sendAlerts = (alerts, title, res) => { - if (!(alerts.length > 0)) { - return; - } - for (const alert of Array.from(alerts)) { - let line = `- *${alert.name}* (${alert.id}): \`${alert.state}\``; - if (alert.newStateDate) { - line += `\n last state change: ${alert.newStateDate}`; - } - if (alert.executionError) { - line += `\n execution error: ${alert.executionError}`; - } - title = `${title + line}\n`; - } - res.send(title.trim()); - }; - /** * Sends the list of dashboards. - * @param {any} dashboards the list of dashboards + * @param {Array} dashboards the list of dashboards * @param {string} title the title that is printed before the result * @param {Hubot.Response} res the context. - * @returns */ - const sendDashboardList = (dashboards, title, res) => { + async function sendDashboardList(dashboards, title, res) { let remaining; robot.logger.debug(dashboards); if (!(dashboards.length > 0)) { @@ -477,9 +225,9 @@ module.exports = (robot) => { } remaining = 0; - if (dashboards.length > max_return_dashboards) { - remaining = dashboards.length - max_return_dashboards; - dashboards = dashboards.slice(0, max_return_dashboards - 1); + if (dashboards.length > maxReturnDashboards) { + remaining = dashboards.length - maxReturnDashboards; + dashboards = dashboards.slice(0, maxReturnDashboards - 1); } const list = []; @@ -491,62 +239,6 @@ module.exports = (robot) => { list.push(` (and ${remaining} more)`); } - return res.send(title + list.join('\n')); - }; - - // Handle generic errors - - /** - * *Sends an error message. - * @param {string} message the error message. - * @param {Hubot.Response} res the response context. - */ - const sendError = (message, res) => { - robot.logger.error(message); - res.send(message); - }; - - // Format the title with template vars - const formatTitleWithTemplate = (title, template_map) => { - if (!title) { - title = ''; - } - return title.replace(/\$\w+/g, (match) => { - if (template_map[match]) { - return template_map[match]; - } - return match; - }); - }; - - // Fetch an image from provided URL, upload it to S3, returning the resulting URL - const uploadChart = async (res, title, imageUrl, grafanaChartLink) => { - const grafana = createGrafanaClient(res); - if (!grafana) return; - - //1. download the file - let file = null; - - try { - file = await grafana.download(imageUrl); - } catch (err) { - sendError(err, res); - return; - } - - robot.logger.debug(`Uploading file: ${file.body.length} bytes, content-type[${file.contentType}]`); - - adapter.uploader.upload(res, title || 'Image', file, grafanaChartLink); - }; + res.send(title + list.join('\n')); + } }; - -/** - * Gets the room from the context. - * @param {Hubot.Response} res The context. - * @returns {string} - */ -function get_room(res) { - // placeholder for further adapter support (i.e. MS Teams) as then room also - // contains thread conversation id - return res.envelope.room; -} diff --git a/src/service/GrafanaService.js b/src/service/GrafanaService.js new file mode 100644 index 0000000..cc4a0c7 --- /dev/null +++ b/src/service/GrafanaService.js @@ -0,0 +1,418 @@ +/// + +const { GrafanaDashboardRequest } = require('./query/GrafanaDashboardRequest'); +const { GrafanaClient } = require('../grafana-client'); + +class GrafanaService { + /** + * Represents a Grafana service. + * @constructor + * @param {GrafanaClient} client - The client object used for communication with Grafana. + */ + constructor(client) { + /** + * The client. + * @type {GrafanaClient} + */ + this.client = client; + + /** + * The logger. + * @type {Hubot.Log} + */ + this.logger = client.logger; + } + + /** + * Processes the given string and returns an array of screenshot URLs for the requested dashboards. + * + * @param {string} str - The string to be processed. + * @param {number} maxReturnDashboards - The maximum number of dashboard screenshots to return. + * @returns {Promise|null>} An array of DashboardResponse objects containing the screenshot URLs. + */ + async process(str, maxReturnDashboards) { + const request = this.parseToGrafanaDashboardRequest(str); + if (!request) { + return null; + } + + const dashboard = await this.getDashboard(request.uid); + if (!dashboard) { + return null; + } + + const responses = await this.getDashboardCharts(request, dashboard, maxReturnDashboards); + return responses; + } + + /** + * Parses a string into a GrafanaDashboardRequest object. + * @param {string} str - The string to parse. + * @returns {GrafanaDashboardResponse.Response|null} - The parsed GrafanaDashboardRequest object, or null if the string cannot be parsed. + */ + parseToGrafanaDashboardRequest(str) { + const match = str.match(/([A-Za-z0-9\-\:_]+)(.*)/); + if (!match) return null; + + const request = new GrafanaDashboardRequest(); + request.uid = match[1].trim(); + + // Parse out a specific panel + if (/\:/.test(request.uid)) { + let parts = request.uid.split(':'); + request.uid = parts[0]; + request.visualPanelId = parseInt(parts[1], 10); + if (isNaN(request.visualPanelId)) { + request.visualPanelId = false; + request.pname = parts[1].toLowerCase(); + } + if (/panel-[0-9]+/.test(request.pname)) { + parts = request.pname.split('panel-'); + request.apiPanelId = parseInt(parts[1], 10); + request.pname = false; + } + } + + const remainder = match[2] ? match[2].trim() : ''; + + // Check if we have any extra fields + if (remainder !== '') { + // The order we apply non-variables in + const timeFields = ['from', 'to']; + + for (const part of Array.from(remainder.trim().split(' '))) { + // Check if it's a variable or part of the timespan + + if (part.indexOf('=') >= 0) { + // put query stuff into its own dict + const [partName, partValue] = part.split('='); + + if (partName in request.query) { + request.query[partName] = partValue; + continue; + } else if (partName == 'from') { + request.timespan.from = partValue; + continue; + } else if (partName == 'to') { + request.timespan.to = partValue; + continue; + } + + request.variables = `${request.variables}&var-${part}`; + request.template_params.push({ + name: partName, + value: partValue, + }); + } else if (part == 'kiosk') { + request.query.kiosk = true; + } + // Only add to the timespan if we haven't already filled out from and to + else if (timeFields.length > 0) { + request.timespan[timeFields.shift()] = part.trim(); + } + } + } + + this.logger.debug(str); + this.logger.debug(request.uid); + this.logger.debug(request.timespan); + this.logger.debug(request.variables); + this.logger.debug(request.template_params); + this.logger.debug(request.visualPanelId); + this.logger.debug(request.apiPanelId); + this.logger.debug(request.pname); + + return request; + } + + /** + * Retrieves the dashboard chart URLs for the specified request. + * + * @param {GrafanaDashboardRequest} req - The request object. + * @param {GrafanaDashboardResponse.Response} dashboardResponse - The dashboard response object. + * @param {number} maxReturnDashboards - The maximum number of dashboards to return. + * @returns {Array|null} An array of DashboardResponse objects containing the screenshot URLs. + */ + async getDashboardCharts(req, dashboardResponse, maxReturnDashboards) { + if (!dashboardResponse || dashboardResponse.message) return null; + + let dashboard = dashboardResponse.dashboard; + + if (req.query.kiosk) { + req.query.apiEndpoint = 'd'; + const imageUrl = await this.client.createImageUrl(req.query, req.uid, null, req.timespan, req.variables); + const grafanaChartLink = await this.client.createGrafanaChartLink( + req.query, + req.uid, + null, + req.timespan, + req.variables + ); + const title = dashboard.title; + + const response = { imageUrl, grafanaChartLink, title }; + return [response]; + } + + // Support for templated dashboards + let templateMap = {}; + this.logger.debug(dashboard.templating.list); + if (dashboard.templating.list) { + for (const template of Array.from(dashboard.templating.list)) { + this.logger.debug(template); + if (!template.current) { + continue; + } + + const _param = req.template_params.find((param) => param.name === template.name); + templateMap[`$${template.name}`] = _param ? _param.value : template.current.text; + } + } + + const responses = []; + + // Return dashboard rows + let panelNumber = 0; + for (const row of Array.from(dashboard.rows)) { + for (const panel of Array.from(row.panels)) { + this.logger.debug(panel); + + panelNumber += 1; + // Skip if visual panel ID was specified and didn't match + if (req.visualPanelId && req.visualPanelId !== panelNumber) { + continue; + } + + // Skip if API panel ID was specified and didn't match + if (req.apiPanelId && req.apiPanelId !== panel.id) { + continue; + } + + // Skip if panel name was specified any didn't match + if (req.pname && panel.title.toLowerCase().indexOf(req.pname) === -1) { + continue; + } + + // Build links for message sending + const title = formatTitleWithTemplate(panel.title, templateMap); + const imageUrl = this.client.createImageUrl(req.query, req.uid, panel, req.timespan, req.variables); + const grafanaChartLink = this.client.createGrafanaChartLink( + req.query, + req.uid, + panel, + req.timespan, + req.variables + ); + + responses.push({ imageUrl, grafanaChartLink, title }); + + // Skip if we have already returned max count of dashboards + if (responses.length == maxReturnDashboards) { + break; + } + } + } + + return responses; + } + + /** + * Retrieves the UID of a dashboard by its slug. + * + * @param {string} slug - The slug of the dashboard. + * @returns {Promise} The UID of the dashboard, or undefined if not found. + */ + async getUidBySlug(slug) { + let client = this.client; + + const pageSize = 5000; + let page = 1; + + while (true) { + const url = `search?limit=${pageSize}&page=${encodeURIComponent(slug)}`; + + try { + const items = await client.get(url); + const dashboard = items + .map((i) => ({ + uid: i.uid, + slug: i.url.replace(`/d/${i.uid}/`, ''), + })) + .find((x) => x.slug == slug); + + if (dashboard && dashboard.uid) { + return dashboard.uid; + } + + if (items.length != pageSize) break; + page++; + } catch (err) { + this.logger.error(err, `Error while getting dashboard on URL: ${url}`); + } + } + + return null; + } + + /** + * Retrieves a dashboard from Grafana based on the provided UID. + * @param {string} uid - The UID of the dashboard to retrieve. + * @returns {Promise} - A promise that resolves to the retrieved dashboard object, or null if the dashboard is not found or an error occurs. + */ + async getDashboard(uid) { + const url = `dashboards/uid/${uid}`; + + /** @type {GrafanaDashboardResponse.Response|null} */ + let dashboard = null; + try { + dashboard = await this.client.get(url); + } catch (err) { + this.logger.error(err, `Error while getting dashboard on URL: ${url}`); + return null; + } + + this.logger.debug(dashboard); + + // check if we can improve the error message + if (dashboard && dashboard.message === 'Dashboard not found') { + let realUid = await this.getUidBySlug(uid); + if (realUid) { + dashboard.message = `Try your query again with \`${realUid}\` instead of \`${uid}\``; + } + } + + // Handle refactor done for version 5.0.0+ + if (dashboard && dashboard.dashboard && dashboard.dashboard.panels) { + // Concept of "rows" was replaced by coordinate system + dashboard.dashboard.rows = [dashboard.dashboard]; + } + + return dashboard; + } + + /** + * Searches for dashboards based on the provided query. + * + * @param {string?} query - The search query. + * @param {string?} tag - The tag. + * @returns {Promise|null>} - A promise that resolves into dashboards. + */ + async search(query, tag) { + const search = new URLSearchParams(); + search.append('type', 'dash-db'); + if (query) { + this.logger.debug(query); + search.append('query', query); + } + if (tag) { + this.logger.debug(tag); + search.append('tag', tag); + } + + const url = `search?${search.toString()}`; + + try { + const result = await this.client.get(url); + return result; + } catch { + let errorTitle = query ? 'Error while searching dashboards' : 'Error while listing dashboards'; + errorTitle += `, URL: ${url}`; + this.logger.error(err, errorTitle); + return null; + } + } + + /** + * + * @param {string} state + * @returns {Promise|null}>} + */ + async queryAlerts(state) { + let url = 'alerts'; + if (state) { + url = `alerts?state=${state}`; + } + try { + const result = await this.client.get(url); + return result; + } catch (err) { + this.logger.error(err, `Error while getting alerts on URL: ${url}`); + return null; + } + } + + /** + * Pauses or resumes a single alert. + * + * @param {string} alertId - The ID of the alert to pause or resume. + * @param {boolean} paused - Indicates whether to pause or resume the alert. + * @returns {Promise} - The result message if successful, or null if an error occurred. + */ + async pauseSingleAlert(alertId, paused) { + const url = `alerts/${alertId}/pause`; + + try { + const result = await this.client.post(url, { paused }); + this.logger.debug(result); + return result.message; + } catch (err) { + this.logger.error(err, `Error for URL: ${url}`); + return null; + } + } + + /** + * Pauses alerts in Grafana. + * + * @param {boolean} paused - Indicates whether to pause or resume the alerts. + * @returns {Promise<{total: number, errored: number, success:number}} - An object containing the total number of alerts, the number of alerts that were successfully paused/resumed, and the number of alerts that encountered an error. + */ + async pauseAllAlerts(paused) { + const result = { + total: 0, + errored: 0, + success: 0, + }; + + const alerts = await this.client.get('alerts'); + if (alerts == null || alerts.length == 0) { + return result; + } + + result.total = alerts.length; + + for (const alert of Array.from(alerts)) { + const url = `alerts/${alert.id}/pause`; + try { + await this.client.post(url, { paused }); + result.success++; + } catch (err) { + this.logger.error(err, `Error for URL: ${url}`); + result.errored++; + } + } + + return result; + } +} + +/** + * Formats the title with the provided template map. + * + * @param {string} title - The title to be formatted. + * @param {Record} templateMap - The map containing the template values. + * @returns {string} - The formatted title. + */ +function formatTitleWithTemplate(title, templateMap) { + if (!title) { + title = ''; + } + return title.replace(/\$\w+/g, (match) => { + if (templateMap[match]) { + return templateMap[match]; + } + return match; + }); +} + +exports.GrafanaService = GrafanaService; diff --git a/src/service/query/GrafanaDashboardQuery.js b/src/service/query/GrafanaDashboardQuery.js new file mode 100644 index 0000000..ebb7054 --- /dev/null +++ b/src/service/query/GrafanaDashboardQuery.js @@ -0,0 +1,35 @@ +class GrafanaDashboardQuery { + constructor() { + /** + * @type {number} + */ + this.width = parseInt(process.env.HUBOT_GRAFANA_DEFAULT_WIDTH, 10) || 1000; + + /** + * @type {number} + */ + this.height = parseInt(process.env.HUBOT_GRAFANA_DEFAULT_HEIGHT, 10) || 500; + + /** + * @type {string} + */ + this.tz = process.env.HUBOT_GRAFANA_DEFAULT_TIME_ZONE || ''; + + /** + * @type {string} + */ + this.orgId = process.env.HUBOT_GRAFANA_ORG_ID || ''; + + /** + * @type {string} + */ + this.apiEndpoint = process.env.HUBOT_GRAFANA_API_ENDPOINT || 'd-solo'; + + /** + * @type {boolean} + */ + this.kiosk = false; + } +} + +exports.GrafanaDashboardQuery = GrafanaDashboardQuery; diff --git a/src/service/query/GrafanaDashboardRequest.js b/src/service/query/GrafanaDashboardRequest.js new file mode 100644 index 0000000..170c05d --- /dev/null +++ b/src/service/query/GrafanaDashboardRequest.js @@ -0,0 +1,55 @@ +const { GrafanaDashboardQuery } = require('./GrafanaDashboardQuery'); + +class GrafanaDashboardRequest { + constructor() { + /** + * @type {string} + */ + this.uid = null; + + /** + * @type {string} + */ + this.panel = null; + + /** + * @type {{from: string, to: string}} + */ + this.timespan = { + from: `now-${process.env.HUBOT_GRAFANA_QUERY_TIME_RANGE || '6h'}`, + to: 'now', + }; + + /** + * @type {string} + */ + this.variables = ''; + + /** + * @type {boolean} + */ + this.visualPanelId = false; + + /** + * @type {boolean} + */ + this.apiPanelId = false; + + /** + * @type {boolean} + */ + this.pname = false; + + /** + * @type {GrafanaDashboardQuery} + */ + this.query = new GrafanaDashboardQuery(); + + /** + * @type {Array<{name: string, value: string}>} + */ + this.template_params = []; + } +} + +exports.GrafanaDashboardRequest = GrafanaDashboardRequest; diff --git a/test/common/TestBot.js b/test/common/TestBot.js index bcecb45..cb3e07d 100644 --- a/test/common/TestBot.js +++ b/test/common/TestBot.js @@ -1,14 +1,37 @@ const { Robot, TextMessage } = require('hubot/es2015'); const nock = require('nock'); const grafanaScript = require('../../src/grafana'); +const { Bot } = require('../../src/Bot'); + +/** + * @typedef {Object} Settings + * @property {string?} logLevel - The hubot log level. + * @property {number?} s3UploadStatusCode - The s3 upload status code. + * @property {string?} name - The name of the bot. + * @property {string?} alias - The alias of the bot. + * @property {string?} adapterName - The name of the adapter. + * + * @typedef {Promise & { set(...value:unknown[]):void }} AwaitableValue + */ class TestBotContext { replies = []; sends = []; + /** + * Represents a TestBot. + * @constructor + * @param {Hubot.Robot} robot - The robot. + * @param {Hubot.User} user - The user. + */ constructor(robot, user) { + + /** @type {Hubot.Robot} */ this.robot = robot; + + /** @type {Hubot.User} */ this.user = user; + this.robot.adapter.on('reply', (_, strings) => { this.replies.push(strings.join('\n')); }); @@ -17,9 +40,19 @@ class TestBotContext { this.sends.push(strings.join('\n')); }); + this.bot = new Bot(this.robot); + + /** @type {nock.Scope} */ this.nock = nock; } + /** + * Sends a message and waits for a response of the specified type. + * + * @param {string} message - The message to send. + * @param {string} [responseType='send'] - The type of response to wait for. + * @returns {Promise} A promise that resolves with the response string. + */ async sendAndWaitForResponse(message, responseType = 'send') { return new Promise((done) => { this.robot.adapter.once(responseType, function (_, strings) { @@ -30,6 +63,11 @@ class TestBotContext { }); } + /** + * Sends a message. + * @param {string} message - The message to send. + * @returns {Promise} - A promise that resolves when the message is sent. + */ async send(message) { const id = (Math.random() + 1).toString(36).substring(7); const textMessage = new TextMessage(this.user, "" + message, id); @@ -42,16 +80,27 @@ class TestBotContext { await this.wait(1); } + /** + * Creates an awaitable value. + */ createAwaitableValue() { return createAwaitableValue(); } + /** + * Waits for the specified number of milliseconds. + * @param {number} ms - The number of milliseconds to wait. + * @returns {Promise} - A promise that resolves after the specified time. + */ async wait(ms) { return new Promise((done) => { setTimeout(() => done(), ms); }); } + /** + * Shuts down the bot and performs necessary cleanup tasks. + */ shutdown() { // remove env setup delete process.env.HUBOT_LOG_LEVEL; @@ -76,7 +125,7 @@ class TestBotContext { /** * Creates a value that can be awaited and execute when the * set function is called. - * @returns {Promise & { set(...value:any[]):void }} + * @returns {Promise & { set(...value:unknown[]):void }} */ function createAwaitableValue() { let value; @@ -100,6 +149,10 @@ function createAwaitableValue() { return promise; } +/** + * Sets up the environment variables for testing. + * @param {Settings} settings - The settings object. + */ function setupEnv(settings) { process.env.HUBOT_LOG_LEVEL = settings?.logLevel || 'silent'; process.env.HUBOT_GRAFANA_HOST = 'https://play.grafana.org'; @@ -110,11 +163,15 @@ function setupEnv(settings) { process.env.AWS_SECRET_ACCESS_KEY = 'secret_key'; process.env.NODE_ENV = 'test'; - - nock.cleanAll(); } +/** + * Sets up the nock interceptors for mocking HTTP requests in the test environment. + * @param {Settings} settings - Optional settings for customizing the behavior of the nock interceptors. + */ function setupNock(settings) { + nock.cleanAll(); + nock('https://play.grafana.org') .get('/render/d-solo/97PlYC7Mk/?panelId=3&width=1000&height=500&from=now-6h&to=now') .matchHeader('authorization', 'Bearer xxxxxxxxxxxxxxxxxxxxxxxxx') @@ -141,6 +198,11 @@ function setupNock(settings) { nock.disableNetConnect(); } +/** + * Creates a test bot with the specified settings. + * @param {Settings} settings - The settings for the test bot. + * @returns {Promise} A promise that resolves to a TestBotContext object representing the created test bot. + */ async function createTestBot(settings = null) { setupEnv(settings); setupNock(settings); diff --git a/test/fixtures/v8/dashboard-templating.json b/test/fixtures/v8/dashboard-templating.json index 37ff7e3..fb87d91 100644 --- a/test/fixtures/v8/dashboard-templating.json +++ b/test/fixtures/v8/dashboard-templating.json @@ -1 +1 @@ -{"meta":{"type":"db","canSave":false,"canEdit":true,"canAdmin":false,"canStar":false,"slug":"templating-showcase","url":"/d/000000091/templating-showcase","expires":"0001-01-01T00:00:00Z","created":"2017-01-20T06:38:55Z","updated":"2020-08-18T19:57:10Z","updatedBy":"diana.payton@grafana.com","createdBy":"carl@raintank.io","version":22,"hasAcl":false,"isFolder":false,"folderId":608,"folderUid":"PGJ1Fr4Zz","folderTitle":"Demos","folderUrl":"/dashboards/f/PGJ1Fr4Zz/demos","provisioned":false,"provisionedExternalId":""},"dashboard":{"annotations":{"list":[{"builtIn":1,"datasource":"-- Grafana --","enable":true,"hide":true,"iconColor":"rgba(0, 211, 255, 1)","name":"Annotations \u0026 Alerts","type":"dashboard"}]},"editable":true,"gnetId":null,"graphTooltip":0,"id":91,"iteration":1597780597713,"links":[],"panels":[{"aliasColors":{},"bars":false,"dashLength":10,"dashes":false,"datasource":"$db","fieldConfig":{"defaults":{"custom":{}},"overrides":[]},"fill":1,"fillGradient":0,"gridPos":{"h":7,"w":24,"x":0,"y":0},"hiddenSeries":false,"id":1,"legend":{"avg":false,"current":false,"max":false,"min":false,"show":true,"total":false,"values":false},"lines":true,"linewidth":1,"links":[],"nullPointMode":"connected","percentage":false,"pluginVersion":"7.1.3","pointradius":5,"points":false,"renderer":"flot","seriesOverrides":[],"spaceLength":10,"stack":false,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(apps.backend.$server.counters.requests.count, 2)","textEditor":false}],"thresholds":[],"timeFrom":null,"timeRegions":[],"timeShift":null,"title":"Graph","tooltip":{"msResolution":false,"shared":true,"sort":0,"value_type":"individual"},"type":"graph","xaxis":{"buckets":null,"mode":"time","name":null,"show":true,"values":[]},"yaxes":[{"format":"short","label":null,"logBase":1,"max":null,"min":null,"show":true},{"format":"short","label":null,"logBase":1,"max":null,"min":null,"show":true}],"yaxis":{"align":false,"alignLevel":null}},{"columns":[{"text":"Avg","value":"avg"},{"text":"Total","value":"total"}],"datasource":"$db","fieldConfig":{"defaults":{"custom":{}},"overrides":[]},"fontSize":"100%","gridPos":{"h":7,"w":16,"x":0,"y":7},"id":2,"links":[],"pageSize":null,"scroll":true,"showHeader":true,"sort":{"col":0,"desc":true},"styles":[{"align":"auto","dateFormat":"YYYY-MM-DD HH:mm:ss","pattern":"Time","type":"date"},{"align":"auto","colorMode":null,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"decimals":2,"pattern":"/.*/","thresholds":[],"type":"number","unit":"short"}],"targets":[{"refId":"A","target":"aliasByNode(apps.backend.$server.counters.requests.count, 2)","textEditor":false}],"title":"Table","transform":"timeseries_aggregations","type":"table-old"},{"cacheTimeout":null,"datasource":"$db","fieldConfig":{"defaults":{"custom":{},"mappings":[{"id":0,"op":"=","text":"N/A","type":1,"value":"null"}],"nullValueMode":"connected","thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unit":"none"},"overrides":[]},"gridPos":{"h":7,"w":8,"x":16,"y":7},"id":3,"interval":null,"links":[],"maxDataPoints":100,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"horizontal","reduceOptions":{"calcs":["mean"],"fields":"/^sumSeries(apps.backend.{backend_01,backend_02,backend_03,backend_04}.counters.requests.count)$/","values":false},"textMode":"auto"},"pluginVersion":"7.1.3","targets":[{"refId":"A","target":"sumSeries(apps.backend.$server.counters.requests.count)"}],"title":"Stat","type":"stat"}],"schemaVersion":26,"style":"dark","tags":["graphite","templating"],"templating":{"list":[{"allValue":null,"current":{"text":"server1","value":"server1"},"hide":0,"includeAll":false,"label":null,"multi":false,"name":"custom","options":[{"selected":true,"text":"server1","value":"server1"},{"selected":false,"text":"server2","value":"server2"}],"query":"server1,server2","skipUrlSync":false,"type":"custom"},{"allValue":null,"current":{"tags":[],"text":"All","value":["$__all"]},"datasource":"graphite","definition":"","hide":0,"includeAll":true,"label":null,"multi":true,"name":"server","options":[{"selected":true,"text":"All","value":"$__all"},{"selected":false,"text":"backend_01","value":"backend_01"},{"selected":false,"text":"backend_02","value":"backend_02"},{"selected":false,"text":"backend_03","value":"backend_03"},{"selected":false,"text":"backend_04","value":"backend_04"}],"query":"apps.backend.*","refresh":0,"regex":"","skipUrlSync":false,"sort":0,"tagValuesQuery":null,"tags":[],"tagsQuery":null,"type":"query","useTags":false},{"allValue":null,"current":{"text":"All","value":"$__all"},"datasource":"graphite","definition":"","hide":0,"includeAll":true,"label":null,"multi":true,"name":"dependent","options":[{"selected":true,"text":"All","value":"$__all"},{"selected":false,"text":"counters","value":"counters"}],"query":"apps.backend.$server.*","refresh":0,"regex":"","skipUrlSync":false,"sort":0,"tagValuesQuery":null,"tags":[],"tagsQuery":null,"type":"query","useTags":false},{"current":{"tags":[],"text":"graphite","value":"graphite"},"hide":0,"includeAll":false,"label":null,"multi":false,"name":"db","options":[],"query":"graphite","refresh":1,"regex":"","skipUrlSync":false,"type":"datasource"}]},"time":{"from":"now-6h","to":"now"},"timepicker":{"refresh_intervals":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"time_options":["5m","15m","1h","6h","12h","24h","2d","7d","30d"]},"timezone":"browser","title":"Templating showcase","uid":"000000091","version":22}} \ No newline at end of file +{"meta":{"type":"db","canSave":false,"canEdit":true,"canAdmin":false,"canStar":false,"slug":"templating-showcase","url":"/d/000000091/templating-showcase","expires":"0001-01-01T00:00:00Z","created":"2017-01-20T06:38:55Z","updated":"2020-08-18T19:57:10Z","updatedBy":"diana.payton@grafana.com","createdBy":"carl@raintank.io","version":22,"hasAcl":false,"isFolder":false,"folderId":608,"folderUid":"PGJ1Fr4Zz","folderTitle":"Demos","folderUrl":"/dashboards/f/PGJ1Fr4Zz/demos","provisioned":false,"provisionedExternalId":""},"dashboard":{"annotations":{"list":[{"builtIn":1,"datasource":"-- Grafana --","enable":true,"hide":true,"iconColor":"rgba(0, 211, 255, 1)","name":"Annotations \u0026 Alerts","type":"dashboard"}]},"editable":true,"gnetId":null,"graphTooltip":0,"id":91,"iteration":1597780597713,"links":[],"panels":[{"aliasColors":{},"bars":false,"dashLength":10,"dashes":false,"datasource":"$db","fieldConfig":{"defaults":{"custom":{}},"overrides":[]},"fill":1,"fillGradient":0,"gridPos":{"h":7,"w":24,"x":0,"y":0},"hiddenSeries":false,"id":1,"legend":{"avg":false,"current":false,"max":false,"min":false,"show":true,"total":false,"values":false},"lines":true,"linewidth":1,"links":[],"nullPointMode":"connected","percentage":false,"pluginVersion":"7.1.3","pointradius":5,"points":false,"renderer":"flot","seriesOverrides":[],"spaceLength":10,"stack":false,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(apps.backend.$server.counters.requests.count, 2)","textEditor":false}],"thresholds":[],"timeFrom":null,"timeRegions":[],"timeShift":null,"title":"Graph for $server","tooltip":{"msResolution":false,"shared":true,"sort":0,"value_type":"individual"},"type":"graph","xaxis":{"buckets":null,"mode":"time","name":null,"show":true,"values":[]},"yaxes":[{"format":"short","label":null,"logBase":1,"max":null,"min":null,"show":true},{"format":"short","label":null,"logBase":1,"max":null,"min":null,"show":true}],"yaxis":{"align":false,"alignLevel":null}},{"columns":[{"text":"Avg","value":"avg"},{"text":"Total","value":"total"}],"datasource":"$db","fieldConfig":{"defaults":{"custom":{}},"overrides":[]},"fontSize":"100%","gridPos":{"h":7,"w":16,"x":0,"y":7},"id":2,"links":[],"pageSize":null,"scroll":true,"showHeader":true,"sort":{"col":0,"desc":true},"styles":[{"align":"auto","dateFormat":"YYYY-MM-DD HH:mm:ss","pattern":"Time","type":"date"},{"align":"auto","colorMode":null,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"decimals":2,"pattern":"/.*/","thresholds":[],"type":"number","unit":"short"}],"targets":[{"refId":"A","target":"aliasByNode(apps.backend.$server.counters.requests.count, 2)","textEditor":false}],"title":"Table","transform":"timeseries_aggregations","type":"table-old"},{"cacheTimeout":null,"datasource":"$db","fieldConfig":{"defaults":{"custom":{},"mappings":[{"id":0,"op":"=","text":"N/A","type":1,"value":"null"}],"nullValueMode":"connected","thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unit":"none"},"overrides":[]},"gridPos":{"h":7,"w":8,"x":16,"y":7},"id":3,"interval":null,"links":[],"maxDataPoints":100,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"horizontal","reduceOptions":{"calcs":["mean"],"fields":"/^sumSeries(apps.backend.{backend_01,backend_02,backend_03,backend_04}.counters.requests.count)$/","values":false},"textMode":"auto"},"pluginVersion":"7.1.3","targets":[{"refId":"A","target":"sumSeries(apps.backend.$server.counters.requests.count)"}],"title":"Stat","type":"stat"}],"schemaVersion":26,"style":"dark","tags":["graphite","templating"],"templating":{"list":[{"allValue":null,"current":{"text":"server1","value":"server1"},"hide":0,"includeAll":false,"label":null,"multi":false,"name":"custom","options":[{"selected":true,"text":"server1","value":"server1"},{"selected":false,"text":"server2","value":"server2"}],"query":"server1,server2","skipUrlSync":false,"type":"custom"},{"allValue":null,"current":{"tags":[],"text":"All","value":["$__all"]},"datasource":"graphite","definition":"","hide":0,"includeAll":true,"label":null,"multi":true,"name":"server","options":[{"selected":true,"text":"All","value":"$__all"},{"selected":false,"text":"backend_01","value":"backend_01"},{"selected":false,"text":"backend_02","value":"backend_02"},{"selected":false,"text":"backend_03","value":"backend_03"},{"selected":false,"text":"backend_04","value":"backend_04"}],"query":"apps.backend.*","refresh":0,"regex":"","skipUrlSync":false,"sort":0,"tagValuesQuery":null,"tags":[],"tagsQuery":null,"type":"query","useTags":false},{"allValue":null,"current":{"text":"All","value":"$__all"},"datasource":"graphite","definition":"","hide":0,"includeAll":true,"label":null,"multi":true,"name":"dependent","options":[{"selected":true,"text":"All","value":"$__all"},{"selected":false,"text":"counters","value":"counters"}],"query":"apps.backend.$server.*","refresh":0,"regex":"","skipUrlSync":false,"sort":0,"tagValuesQuery":null,"tags":[],"tagsQuery":null,"type":"query","useTags":false},{"current":{"tags":[],"text":"graphite","value":"graphite"},"hide":0,"includeAll":false,"label":null,"multi":false,"name":"db","options":[],"query":"graphite","refresh":1,"regex":"","skipUrlSync":false,"type":"datasource"}]},"time":{"from":"now-6h","to":"now"},"timepicker":{"refresh_intervals":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"time_options":["5m","15m","1h","6h","12h","24h","2d","7d","30d"]},"timezone":"browser","title":"Templating showcase","uid":"000000091","version":22}} \ No newline at end of file diff --git a/test/grafana-s3-test.js b/test/grafana-s3-test.js index cd7c7b7..d0bf1f5 100644 --- a/test/grafana-s3-test.js +++ b/test/grafana-s3-test.js @@ -52,12 +52,14 @@ describe('s3', () => { let response = await ctx.sendAndWaitForResponse('@hubot graf db AAy9r_bmk:cpu server=ww3.example.com now-6h'); response = response.replace(/\/[a-f0-9]{40}\.png/i, '/abdcdef0123456789.png'); - let panelRegex = /panelId=(\d+)/ + let panelRegex = /panelId=(\d+)/; expect(response).to.match(panelRegex); - let panelId = response.match(panelRegex)[1] + let panelId = response.match(panelRegex)[1]; expect(response).to.eql( - 'CPU: https://graf.s3.us-standard.amazonaws.com/grafana/abdcdef0123456789.png - https://play.grafana.org/d/AAy9r_bmk/?panelId=' + panelId + '&fullscreen&from=now-6h&to=now&var-server=ww3.example.com' + 'CPU: https://graf.s3.us-standard.amazonaws.com/grafana/abdcdef0123456789.png - https://play.grafana.org/d/AAy9r_bmk/?panelId=' + + panelId + + '&fullscreen&from=now-6h&to=now&var-server=ww3.example.com' ); }); }); diff --git a/test/grafana-service-test.js b/test/grafana-service-test.js new file mode 100644 index 0000000..17ac002 --- /dev/null +++ b/test/grafana-service-test.js @@ -0,0 +1,73 @@ +const { expect } = require('chai'); +const { createTestBot, TestBotContext } = require('./common/TestBot'); + +describe('grafana service', () => { + /** @type {TestBotContext} */ + let ctx; + + beforeEach(async () => { + ctx = await createTestBot(); + ctx + .nock('https://play.grafana.org') + .get('/api/dashboards/uid/AAy9r_bmk') + .replyWithFile(200, `${__dirname}/fixtures/v8/dashboard-monitoring-default.json`); + + ctx + .nock('https://play.grafana.org') + .get(/\/api\/search/) + .replyWithFile(200, `${__dirname}/fixtures/v8/search-tag.json`); + + [3, 7, 8].map((i) => + ctx + .nock('https://play.grafana.org') + .defaultReplyHeaders({ 'Content-Type': 'image/png' }) + .get('/render/d-solo/AAy9r_bmk/') + .query({ + panelId: i, + width: 1000, + height: 500, + from: 'now-6h', + to: 'now', + 'var-server': 'ww3.example.com', + }) + .replyWithFile(200, `${__dirname}/fixtures/v8/dashboard-monitoring-default.png`) + ); + }); + + afterEach(function () { + ctx?.shutdown(); + }); + + it('should process response', async () => { + const service = ctx.bot.createService({ robot: ctx.robot }); + const result = await service.process('AAy9r_bmk:cpu server=ww3.example.com now-6h', 2); + + expect(result).to.be.not.null; + expect(result).to.be.of.length(2); + + expect(result[0].grafanaChartLink).to.eql( + 'https://play.grafana.org/d/AAy9r_bmk/?panelId=3&fullscreen&from=now-6h&to=now&var-server=ww3.example.com' + ); + expect(result[0].imageUrl).to.eql( + 'https://play.grafana.org/render/d-solo/AAy9r_bmk/?panelId=3&width=1000&height=500&from=now-6h&to=now&var-server=ww3.example.com' + ); + expect(result[0].title).to.eql('CPU'); + + expect(result[1].grafanaChartLink).to.eql( + 'https://play.grafana.org/d/AAy9r_bmk/?panelId=7&fullscreen&from=now-6h&to=now&var-server=ww3.example.com' + ); + expect(result[1].imageUrl).to.eql( + 'https://play.grafana.org/render/d-solo/AAy9r_bmk/?panelId=7&width=1000&height=500&from=now-6h&to=now&var-server=ww3.example.com' + ); + expect(result[1].title).to.eql('CPU'); + }); + + it('should resolve UID by slug', async () => { + const service = ctx.bot.createService({ robot: ctx.robot }); + const result = await service.getUidBySlug("the-four-golden-signals"); + expect(result).to.eql('000000109'); + }); + + + +}); diff --git a/test/grafana-slack-test.js b/test/grafana-slack-test.js index daac471..715d5fe 100644 --- a/test/grafana-slack-test.js +++ b/test/grafana-slack-test.js @@ -2,8 +2,47 @@ const { expect } = require('chai'); const { createTestBot, TestBotContext, createAwaitableValue } = require('./common/TestBot'); const { SlackResponder } = require('../src/adapters/implementations/SlackResponder'); const { SlackUploader } = require('../src/adapters/implementations/SlackUploader'); +const { setResponder, clearResponder } = require('../src/adapters/Adapter'); +const { Responder } = require('../src/adapters/Responder'); describe('slack', () => { + describe('and override responder upload', () => { + class CustomResponder extends Responder { + /** + * Sends the response to Hubot. + * @param {Hubot.Response} res the context. + * @param {string} title the title of the message + * @param {string} image the URL of the image + * @param {string} link the title of the link + */ + send(res, title, image, link) { + res.send('Hiding dashboard: ' + title); + } + } + + /** @type {TestBotContext} */ + let ctx; + + beforeEach(async () => { + setResponder(new CustomResponder()); + process.env.HUBOT_GRAFANA_S3_BUCKET = 'graf'; + ctx = await createTestBot({ + adapterName: 'hubot-slack', + }); + }); + + afterEach(function () { + delete process.env.HUBOT_GRAFANA_S3_BUCKET; + clearResponder(); + ctx?.shutdown(); + }); + + it('should respond with an uploaded graph', async () => { + let response = await ctx.sendAndWaitForResponse('@hubot graf db 97PlYC7Mk:panel-3'); + expect(response).to.eql('Hiding dashboard: logins'); + }); + }); + describe('and s3 upload', () => { /** @type {TestBotContext} */ let ctx; diff --git a/test/grafana-v8-test.js b/test/grafana-v8-test.js index 083aaee..625f54c 100644 --- a/test/grafana-v8-test.js +++ b/test/grafana-v8-test.js @@ -109,8 +109,7 @@ describe('grafana v8', () => { .reply(404, { message: 'Dashboard not found' }); ctx .nock('https://play.grafana.org') - .get('/api/search') - .query({ type: 'dash-db' }) + .get(/\/api\/search/) .replyWithFile(200, `${__dirname}/fixtures/v8/search.json`); ctx .nock('https://play.grafana.org') @@ -251,7 +250,7 @@ describe('grafana v8', () => { it('hubot should respond with a templated graph', async () => { let response = await ctx.sendAndWaitForResponse('hubot graf db 000000091:graph server=backend_01 now-6h'); expect(response).to.eql( - 'Graph: https://play.grafana.org/render/d-solo/000000091/?panelId=1&width=1000&height=500&from=now-6h&to=now&var-server=backend_01 - https://play.grafana.org/d/000000091/?panelId=1&fullscreen&from=now-6h&to=now&var-server=backend_01' + 'Graph for backend_01: https://play.grafana.org/render/d-solo/000000091/?panelId=1&width=1000&height=500&from=now-6h&to=now&var-server=backend_01 - https://play.grafana.org/d/000000091/?panelId=1&fullscreen&from=now-6h&to=now&var-server=backend_01' ); }); }); @@ -299,4 +298,50 @@ describe('grafana v8', () => { expect(response).to.eql('alert un-paused'); }); }); + + describe('ask hubot to pause all alerts', () => { + beforeEach(async () => { + ctx + .nock('https://play.grafana.org') + .get('/api/alerts') + .reply(200, [{ id: 1 }]); + + ctx + .nock('https://play.grafana.org') + .post('/api/alerts/1/pause', { paused: true }) + .reply(200, { alertId: 1, message: 'alert paused' }); + }); + + it('hubot should respond with a successful paused response', async () => { + let response = await ctx.sendAndWaitForResponse('hubot graf pause all alerts'); + expect(response).to.eql( + "Successfully tried to pause *1* alerts.\n" + + "*Success: 1*\n" + + "*Errored: 0*" + ); + }); + }); + + describe('ask hubot to un-pause all alerts', () => { + beforeEach(async () => { + ctx + .nock('https://play.grafana.org') + .get('/api/alerts') + .reply(200, [{ id: 1 }]); + + ctx + .nock('https://play.grafana.org') + .post('/api/alerts/1/pause', { paused: false }) + .reply(200, { alertId: 1, message: 'alert un-paused' }); + }); + + it('hubot should respond with a successful un-paused response', async () => { + let response = await ctx.sendAndWaitForResponse('hubot graf unpause all alerts'); + expect(response).to.eql( + "Successfully tried to unpause *1* alerts.\n" + + "*Success: 1*\n" + + "*Errored: 0*" + ); + }); + }); }); diff --git a/test/kiosk-test.js b/test/kiosk-test.js index 7a0ee03..d01e840 100644 --- a/test/kiosk-test.js +++ b/test/kiosk-test.js @@ -27,7 +27,7 @@ describe('retrieve dashboard graphs', function () { it('should respond with multiple graphs without kiosk mode', async () => { await ctx.sendAndWaitForResponse('hubot graf db 000000091'); expect(ctx.sends).to.eql([ - 'Graph: https://play.grafana.org/render/d-solo/000000091/?panelId=1&width=1000&height=500&from=now-6h&to=now - https://play.grafana.org/d/000000091/?panelId=1&fullscreen&from=now-6h&to=now', + 'Graph for All: https://play.grafana.org/render/d-solo/000000091/?panelId=1&width=1000&height=500&from=now-6h&to=now - https://play.grafana.org/d/000000091/?panelId=1&fullscreen&from=now-6h&to=now', 'Table: https://play.grafana.org/render/d-solo/000000091/?panelId=2&width=1000&height=500&from=now-6h&to=now - https://play.grafana.org/d/000000091/?panelId=2&fullscreen&from=now-6h&to=now', 'Stat: https://play.grafana.org/render/d-solo/000000091/?panelId=3&width=1000&height=500&from=now-6h&to=now - https://play.grafana.org/d/000000091/?panelId=3&fullscreen&from=now-6h&to=now', ]); diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..aa5a136 --- /dev/null +++ b/types.d.ts @@ -0,0 +1,328 @@ +type DashboardChart = { + imageUrl: string; + grafanaChartLink: grafanaChartLink; + title: string; +}; + +namespace GrafanaDashboardResponse { + type Response = { + meta: Meta; + dashboard: Dashboard; + message?: string; + }; + + type Dashboard = { + annotations: Annotations; + editable: boolean; + gnetId: null; + graphTooltip: number; + id: number; + links: any[]; + panels: Panel[]; + refresh: boolean; + schemaVersion: number; + style: string; + tags: string[]; + templating: Templating; + time: Time; + timepicker: Timepicker; + timezone: string; + title: string; + uid: string; + version: number; + rows: Array; + }; + + type Annotations = { + enable: boolean; + list: AnnotationsList[]; + }; + + type AnnotationsList = { + builtIn: number; + datasource: string; + enable: boolean; + hide: boolean; + iconColor: string; + name: string; + type: string; + }; + + type Panel = { + cacheTimeout?: null; + colorBackground?: boolean; + colorValue?: boolean; + colors?: string[]; + datasource: string; + format?: string; + gauge?: Gauge; + gridPos: GridPos; + id: number; + interval?: null | string; + links: any[]; + mappingType?: number; + mappingTypes?: MappingType[]; + maxDataPoints?: number; + nullPointMode: string; + nullText?: null; + postfix?: string; + postfixFontSize?: string; + prefix?: string; + prefixFontSize?: string; + rangeMaps?: RangeMap[]; + repeat?: null | string; + repeatDirection?: string; + scopedVars?: ScopedVars; + sparkline?: Sparkline; + tableColumn?: string; + targets: Target[]; + thresholds: any[] | string; + title: string; + type: string; + valueFontSize?: string; + valueMaps?: ValueMap[]; + valueName?: string; + repeatIteration?: number; + repeatPanelId?: number; + aliasColors?: AliasColors; + annotate?: Annotate; + bars?: boolean; + dashLength?: number; + dashes?: boolean; + editable?: boolean; + error?: boolean; + fill?: number; + grid?: AliasColors; + legend?: { [key: string]: boolean }; + lines?: boolean; + linewidth?: number; + percentage?: boolean; + pointradius?: number; + points?: boolean; + renderer?: string; + resolution?: number; + scale?: number; + seriesOverrides?: any[]; + spaceLength?: number; + stack?: boolean; + steppedLine?: boolean; + timeFrom?: null; + timeShift?: null; + tooltip?: Tooltip; + xaxis?: Xaxis; + yaxes?: Yax[]; + yaxis?: Yaxis; + zerofill?: boolean; + }; + + type AliasColors = {}; + + type Annotate = { + enable: boolean; + }; + + type Gauge = { + maxValue: number; + minValue: number; + show: boolean; + thresholdLabels: boolean; + thresholdMarkers: boolean; + }; + + type GridPos = { + h: number; + w: number; + x: number; + y: number; + }; + + type MappingType = { + name: string; + value: number; + }; + + type RangeMap = { + from: string; + text: string; + to: string; + }; + + type ScopedVars = { + host: Host; + }; + + type Host = { + selected: boolean; + text: string; + value: string; + }; + + type Sparkline = { + fillColor: string; + full: boolean; + lineColor: string; + show: boolean; + }; + + type Target = { + groupBy: GroupBy[]; + measurement: string; + orderByTime: string; + policy: string; + refId: string; + resultFormat: string; + select: Array; + tags: Tag[]; + target: string; + alias?: string; + dsType?: string; + query?: string; + }; + + type GroupBy = { + params: string[]; + type: string; + }; + + type Tag = { + key: string; + operator: string; + value: string; + condition?: string; + }; + + type Tooltip = { + msResolution?: boolean; + query_as_alias?: boolean; + shared: boolean; + sort: number; + value_type: string; + }; + + type ValueMap = { + op: string; + text: string; + value: string; + }; + + type Xaxis = { + buckets: null; + mode: string; + name: null; + show: boolean; + values: any[]; + }; + + type Yax = { + format: string; + logBase: number; + max: null; + min: null; + show: boolean; + label?: null; + }; + + type Yaxis = { + align: boolean; + alignLevel: null; + }; + + type Templating = { + list: TemplatingList[]; + }; + + type TemplatingList = { + allValue?: null; + current?: Current; + datasource: null | string; + hide: number; + includeAll?: boolean; + label: null | string; + multi?: boolean; + name: string; + options?: Host[]; + query?: string; + refresh?: number; + regex?: string; + skipUrlSync: boolean; + sort?: number; + tagValuesQuery?: null; + tags?: any[]; + tagsQuery?: null; + type: string; + useTags?: boolean; + allFormat?: string; + multiFormat?: string; + refresh_on_load?: boolean; + auto?: boolean; + auto_count?: number; + auto_min?: string; + filters?: any[]; + }; + + type Current = { + tags: any[]; + text: string; + value: string[] | string; + selected?: boolean; + }; + + type Time = { + from: string; + to: string; + }; + + type Timepicker = { + collapse: boolean; + enable: boolean; + notice: boolean; + now: boolean; + refresh_intervals: string[]; + status: string; + time_options: string[]; + type: string; + }; + + type Meta = { + type: string; + canSave: boolean; + canEdit: boolean; + canAdmin: boolean; + canStar: boolean; + slug: string; + url: string; + expires: Date; + created: Date; + updated: Date; + updatedBy: string; + createdBy: string; + version: number; + hasAcl: boolean; + isFolder: boolean; + folderId: number; + folderUid: string; + folderTitle: string; + folderUrl: string; + provisioned: boolean; + provisionedExternalId: string; + }; +} + +type GrafanaSearchResponse = { + id: number; + uid: string; + title: string; + uri: string; + url: string; + slug: string; + type: string; + tags: string[]; + isStarred: boolean; + folderId: number; + folderUid: string; + folderTitle: string; + folderUrl: string; + sortMeta: number; +}; + +type DownloadedFile = { body: Buffer; contentType: string };