Skip to content

Commit

Permalink
Merge pull request #182 from stephenyeargin/feature/fetch-all-tha-thingz
Browse files Browse the repository at this point in the history
Bye bye `robot.http`, hello `fetch`
  • Loading branch information
KeesCBakker authored May 1, 2024
2 parents f20c3a7 + 23432ee commit 30dd8c0
Show file tree
Hide file tree
Showing 13 changed files with 237 additions and 199 deletions.
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye

# Install the npm packages globally
RUN npm install -g npm \
RUN npm install -g npm@10.7.0 \
&& npm install -g npm-check-updates

COPY ./startup.sh /
Expand Down
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"extensions": [
"esbenp.prettier-vscode",
"GitHub.copilot",
"streetsidesoftware.code-spell-checker"
"streetsidesoftware.code-spell-checker",
"hbenl.vscode-mocha-test-adapter"
]
}
}
Expand Down
8 changes: 8 additions & 0 deletions .mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"reporter": "spec",
"color": true,
"full-trace": true,
"experimental-fetch": false,
"timeout": 200,
"spec": "test/**/*-test.js"
}
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
"cSpell.words": [
"autofitpanels",
"datasource",
"esbenp",
"gnet",
"hbenl",
"linewidth",
"pointradius",
"sparkline",
"stephenyeargin",
"templating",
"timespan",
"xaxis",
"yaxes",
"yaxis",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
},
"main": "index.js",
"scripts": {
"test": "mocha \"test/**/*.js\" --reporter spec --no-experimental-fetch --timeout 200",
"test-with-coverage": "nyc --reporter=text mocha \"test/**/*.js\" --reporter spec",
"test": "mocha",
"test-with-coverage": "nyc --reporter=text mocha",
"bootstrap": "script/bootstrap",
"prepare": "husky",
"lint": "eslint src/ test/"
Expand Down
48 changes: 45 additions & 3 deletions src/Bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,53 @@ class Bot {
return null;
}

let client = new GrafanaClient(robot.http, robot.logger, host, apiKey);
let client = new GrafanaClient(robot.logger, host, apiKey);
return new GrafanaService(client);
}

/**
* Sends dashboard charts based on a request string.
*
* @param {Hubot.Response} context - The context object.
* @param {string} requestString - The request string. This string may contain all the parameters to fetch a dashboard (should not contain the `@hubot graf db` part).
* @param {number} maxReturnDashboards - The maximum number of dashboards to return.
* @returns {Promise<void>} - A promise that resolves when the charts are sent.
*/
async sendDashboardChartFromString(context, requestString, maxReturnDashboards = null) {
const service = this.createService(context);
if (service == null) return;

const req = service.parseToGrafanaDashboardRequest(requestString);
const dashboard = await service.getDashboard(req.uid);

// Check dashboard information
if (!dashboard) {
return this.sendError('An error ocurred. Check your logs for more details.', context);
}

if (dashboard.message) {
return this.sendError(dashboard.message, context);
}

// Defaults
const data = dashboard.dashboard;

// Handle empty dashboard
if (data.rows == null) {
return this.sendError('Dashboard empty.', context);
}

maxReturnDashboards = maxReturnDashboards || parseInt(process.env.HUBOT_GRAFANA_MAX_RETURNED_DASHBOARDS, 10) || 25;
const charts = await service.getDashboardCharts(req, dashboard, maxReturnDashboards);
if (charts == null || charts.length === 0) {
return this.sendError('Could not locate desired panel.', context);
}

for (let chart of charts) {
await this.sendDashboardChart(context, chart);
}
}

/**
* Sends a dashboard chart.
*
Expand All @@ -68,8 +111,7 @@ class Bot {
try {
file = await service.client.download(dashboard.imageUrl);
} catch (err) {
this.sendError(err, context);
return;
return this.sendError(err, context);
}

this.logger.debug(`Uploading file: ${file.body.length} bytes, content-type[${file.contentType}]`);
Expand Down
155 changes: 95 additions & 60 deletions src/adapters/implementations/RocketChatUploader.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
'strict';
const { post } = require('../../http');
const { Uploader } = require('../Uploader');

class RocketChatUploader extends Uploader {
Expand All @@ -20,7 +19,11 @@ class RocketChatUploader extends Uploader {
/** @type {string} */
this.rocketchat_url = process.env.ROCKETCHAT_URL;

if (this.rocketchat_url && !this.rocketchat_url.startsWith('http://') && !this.rocketchat_url.startsWith('https://')) {
if (
this.rocketchat_url &&
!this.rocketchat_url.startsWith('http://') &&
!this.rocketchat_url.startsWith('https://')
) {
this.rocketchat_url = `http://${rocketchat_url}`;
}

Expand All @@ -31,6 +34,40 @@ class RocketChatUploader extends Uploader {
this.logger = logger;
}

/**
* Logs in to the RocketChat API using the provided credentials.
* @returns {Promise<{'X-Auth-Token': string, 'X-User-Id': string}>} A promise that resolves to the authentication headers if successful.
* @throws {Error} If authentication fails.
*/
async login() {
const authUrl = `${this.rocketchat_url}/api/v1/login`;
const authForm = {
username: this.rocketchat_user,
password: this.rocketchat_password,
};

let rocketchatResBodyJson = null;

try {
rocketchatResBodyJson = await post(authUrl, authForm);
} catch (err) {
this.logger.error(err);
throw new Error('Could not authenticate.');
}

const { status } = rocketchatResBodyJson;
if (status === 'success') {
return {
'X-Auth-Token': rocketchatResBodyJson.data.authToken,
'X-User-Id': rocketchatResBodyJson.data.userId,
};
}

const errMsg = rocketchatResBodyJson.message;
this.logger.error(errMsg);
throw new Error(errMsg);
}

/**
* Uploads the a screenshot of the dashboards.
*
Expand All @@ -39,69 +76,67 @@ class RocketChatUploader extends Uploader {
* @param {{ body: Buffer, contentType: string}=>void} file the screenshot.
* @param {string} grafanaChartLink link to the Grafana chart.
*/
upload(res, title, file, grafanaChartLink) {
const authData = {
url: `${this.rocketchat_url}/api/v1/login`,
form: {
username: this.rocketchat_user,
password: this.rocketchat_password,
async upload(res, title, file, grafanaChartLink) {
let authHeaders = null;
try {
authHeaders = await this.login();
} catch (ex) {
let msg = ex == 'Could not authenticate.' ? "invalid url, user or password/can't access rocketchat api" : ex;
res.send(`${title} - [Rocketchat auth Error - ${msg}] - ${grafanaChartLink}`);
return;
}

// fill in the POST request. This must be www-form/multipart
// TODO: needs some extra testing!
const uploadUrl = `${this.rocketchat_url}/api/v1/rooms.upload/${res.envelope.user.roomID}`;
const uploadForm = {
msg: `${title}: ${grafanaChartLink}`,
// grafanaDashboardRequest() is the method that downloads the .png
file: {
value: file.body,
options: {
filename: `${title} ${Date()}.png`,
contentType: 'image/png',
},
},
};

// We auth against rocketchat to obtain the auth token
post(robot, authData, async (err, rocketchatResBodyJson) => {
if (err) {
this.logger.error(err);
res.send(`${title} - [Rocketchat auth Error - invalid url, user or password/can't access rocketchat api] - ${grafanaChartLink}`);
return;
}
let errMsg;
const { status } = rocketchatResBodyJson;
if (status !== 'success') {
errMsg = rocketchatResBodyJson.message;
this.logger.error(errMsg);
res.send(`${title} - [Rocketchat auth Error - ${errMsg}] - ${grafanaChartLink}`);
return;
}

const auth = rocketchatResBodyJson.data;

// fill in the POST request. This must be www-form/multipart
// TODO: needs some extra testing!
const uploadData = {
url: `${this.rocketchat_url}/api/v1/rooms.upload/${res.envelope.user.roomID}`,
headers: {
'X-Auth-Token': auth.authToken,
'X-User-Id': auth.userId,
},
formData: {
msg: `${title}: ${grafanaChartLink}`,
// grafanaDashboardRequest() is the method that downloads the .png
file: {
value: file.body,
options: {
filename: `${title} ${Date()}.png`,
contentType: 'image/png',
},
},
},
};
let body = null;

try {
body = await this.post(uploadUrl, uploadForm, authHeaders);
} catch (err) {
this.logger.error(err);
res.send(`${title} - [Upload Error] - ${grafanaChartLink}`);
return;
}

if (!body.success) {
this.logger.error(`rocketchat service error while posting data:${body.error}`);
return res.send(`${title} - [Form Error: can't upload file : ${body.error}] - ${grafanaChartLink}`);
}
}

// Try to upload the image to rocketchat else pass the link over
return post(this.robot, uploadData, (err, body) => {
// Error logging, we must also check the body response.
// It will be something like: { "success": <boolean>, "error": <error message> }
if (err) {
this.logger.error(err);
return res.send(`${title} - [Upload Error] - ${grafanaChartLink}`);
}
if (!body.success) {
errMsg = body.error;
this.logger.error(`rocketchat service error while posting data:${errMsg}`);
return res.send(`${title} - [Form Error: can't upload file : ${errMsg}] - ${grafanaChartLink}`);
}
});
/**
* Posts the data data to the specified url and returns JSON.
* @param {string} url - the URL
* @param {Record<string, unknown>} formData - formatData
* @param {Record<string, string>|null} headers - formatData
* @returns {Promise<unknown>} The deserialized JSON response or an error if something went wrong.
*/
async post(url, formData, headers = null) {
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: new FormData(formData),
});

if (!response.ok) {
throw new Error('HTTP request failed');
}

const data = await response.json();
return data;
}
}

Expand Down
Loading

0 comments on commit 30dd8c0

Please sign in to comment.