├── .devcontainer ├── .profile ├── Dockerfile ├── devcontainer.json └── startup.sh ├── .editorconfig ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── nodejs.yml │ └── npm-publish.yml ├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── pre-push ├── .mocharc.json ├── .prettierrc ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── script └── bootstrap ├── src ├── Bot.js ├── adapters │ ├── Adapter.js │ ├── Responder.js │ ├── Uploader.js │ └── implementations │ │ ├── BearyChatResponder.js │ │ ├── HipChatResponder.js │ │ ├── RocketChatUploader.js │ │ ├── S3Uploader.js │ │ ├── SlackResponder.js │ │ ├── SlackUploader.js │ │ └── TelegramUploader.js ├── grafana-client.js ├── grafana.js └── service │ ├── GrafanaService.js │ └── query │ ├── GrafanaDashboardQuery.js │ └── GrafanaDashboardRequest.js ├── test ├── adapters-test.js ├── adapters │ ├── rocketchat.js │ └── slack.js ├── common │ └── TestBot.js ├── fixtures │ ├── rocketchat │ │ ├── login.json │ │ └── rooms.upload.json │ ├── slack │ │ ├── auth.test.json │ │ └── files.upload.json │ └── v8 │ │ ├── alerts.json │ │ ├── dashboard-grafana-play.json │ │ ├── dashboard-grafana-play.png │ │ ├── dashboard-monitoring-default.json │ │ ├── dashboard-monitoring-default.png │ │ ├── dashboard-templating.json │ │ ├── search-query.json │ │ ├── search-tag.json │ │ └── search.json ├── grafana-rocketchat-test.js ├── grafana-s3-test.js ├── grafana-service-test.js ├── grafana-slack-test.js ├── grafana-telegram-test.js ├── grafana-v8-test.js ├── kiosk-test.js ├── per-room-configuration-test.js ├── static-configuration-test.js └── timezone-test.js └── types.d.ts /.devcontainer/.profile: -------------------------------------------------------------------------------- 1 | # trust the repo 2 | # fixes: 3 | # - fatal: detected dubious ownership in repository at '/workspaces/bot-zero'. 4 | git config --global --add safe.directory "$PWD" 5 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye 2 | 3 | # Install the npm packages globally 4 | RUN npm install -g npm@10.9.2 \ 5 | && npm install -g npm-check-updates 6 | 7 | COPY ./startup.sh / 8 | RUN chmod +x /startup.sh 9 | 10 | # Append the profile to the current .bashrc and .zshrc files 11 | # this makes sure we keep the current behavior like colors and aliases 12 | COPY ./.profile /tmp/.profile 13 | RUN cat /tmp/.profile >> /home/node/.bashrc && \ 14 | cat /tmp/.profile >> /home/node/.zshrc && \ 15 | rm /tmp/.profile 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "dockerFile": "Dockerfile", 6 | "postStartCommand": "/startup.sh", 7 | // Features to add to the dev container. More info: https://containers.dev/features. 8 | // "features": {}, 9 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 10 | // "forwardPorts": [], 11 | // Use 'postCreateCommand' to run commands after the container is created. 12 | // "postCreateCommand": "yarn install", 13 | // Configure tool-specific properties. 14 | // "customizations": {}, 15 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 16 | // "remoteUser": "root" 17 | "remoteUser": "node", 18 | "mounts": [ 19 | "source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume" 20 | ], 21 | "postCreateCommand": "sudo chown node node_modules", 22 | "customizations": { 23 | "vscode": { 24 | "extensions": [ 25 | "esbenp.prettier-vscode", 26 | "GitHub.copilot", 27 | "streetsidesoftware.code-spell-checker", 28 | "hbenl.vscode-mocha-test-adapter" 29 | ] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.devcontainer/startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # install NPM packages 4 | echo "" 5 | echo "Installing packages..." 6 | npm install --no-audit --no-fund 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | sinon: true, 6 | }, 7 | extends: 'airbnb-base', 8 | overrides: [ 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 'latest', 12 | sourceType: 'module', 13 | }, 14 | rules: { 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Data source '...' 13 | 2. Command entered '....' 14 | 3. See error 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Software:** 23 | - Grafana: 24 | - Hubot: 25 | - Adapter: 26 | - Node: 27 | - NPM: 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | node-version: [18.x, 20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: npm install, build 26 | run: | 27 | npm run bootstrap 28 | npm run build --if-present 29 | env: 30 | CI: true 31 | 32 | - name: npm test 33 | run: | 34 | npm run bootstrap 35 | npm test -- --forbid-only --forbid-pending 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: main 4 | 5 | name: npm-publish 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "20" 18 | - run: npm ci 19 | - run: npm test -- --forbid-only --forbid-pending 20 | - uses: JS-DevTools/npm-publish@v3 21 | id: publish 22 | with: 23 | token: ${{ secrets.NPM_AUTH_TOKEN }} 24 | - if: ${{ steps.publish.outputs.type }} 25 | name: Create Release 26 | env: 27 | GH_TOKEN: ${{ github.token }} 28 | run: | 29 | VERSION="v${{ steps.publish.outputs.version }}" 30 | gh release create $VERSION --generate-notes 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 28 | node_modules 29 | 30 | # Optional npm cache directory 31 | .npm 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | # IDE 37 | .idea 38 | 39 | # Code coverage 40 | coverage.json 41 | .coveralls.yml 42 | 43 | # Node versions (nodenv) 44 | .node-version 45 | 46 | # npm pack result 47 | *.tgz 48 | 49 | .nyc_output 50 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test -- --forbid-only --forbid-pending 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test -- --forbid-only --forbid-pending 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": "spec", 3 | "color": true, 4 | "full-trace": true, 5 | "experimental-fetch": false, 6 | "timeout": 200, 7 | "spec": "test/**/*-test.js" 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "useTabs": false, 4 | "tabWidth": 2, 5 | "endOfLine": "lf", 6 | "printWidth": 120, 7 | "trailingComma": "es5", 8 | "singleQuote": true 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "autofitpanels", 4 | "datasource", 5 | "esbenp", 6 | "gnet", 7 | "hbenl", 8 | "linewidth", 9 | "pointradius", 10 | "sparkline", 11 | "stephenyeargin", 12 | "templating", 13 | "timespan", 14 | "xaxis", 15 | "yaxes", 16 | "yaxis", 17 | "Yeargin" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hubot-grafana@yearg.in. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to hubot-grafana 2 | 3 | We are proud to have [several contributors](https://github.com/stephenyeargin/hubot-grafana/graphs/contributors) to this Hubot Script Package and want you to be one! Here are some general suggestions to help make sure your PR is merged quickly. 4 | 5 | This follows the standard [GitHub Flow](https://guides.github.com/introduction/flow/), with some notes about working with NPM modules. 6 | 7 | 1. [Open a GitHub Issue](https://github.com/stephenyeargin/hubot-grafana/issues/new) before starting. This is a great way to get feedback from other users on your idea and helps clarify what the upcoming PR will accomplish. 8 | 2. [Fork the repository](https://github.com/stephenyeargin/hubot-grafana/fork) to your account or organization. 9 | 3. Clone the repository locally and run `npm install` to download the necessary dependencies. 10 | 4. Run the test suite with `npm test` in your cloned repository. This is to make sure you've got everything you need to get started. 11 | 5. Use `npm link` in the cloned repository and then run `nmp link hubot-grafana` in your Hubot checkout to connect your cloned version to your local Hubot install. Now you can test changes with your own data! 12 | 6. Commit and push changes back to your forked repository. 13 | 7. [Open a Pull Request](https://github.com/stephenyeargin/hubot-grafana/compare) with your repository against the parent one to submit your changes. 14 | 8. See if the the CI tests that run automatically upon opening a Pull Request pass. If not, double check your work locally with `npm test` again to resolve any issues. 15 | 16 | Note: You won't need to do a version bump in the `package.json` file as we have a [Grunt](http://gruntjs.com) task for handling that. 17 | 18 | ## Tips 19 | 20 | - Test coverage makes the world go round. If you add a feature or fix a bug, be sure to adjust the tests to account for it when practical. 21 | - Configuration options are preferable to changing something globally. Many folks may depend on the current behavior (e.g. the default time window) and want to leave the default in place. 22 | - This package is designed to work with all Hubot adapters, so we are not wanting to limit it to only folks who use Slack, HipChat, etc. See [`robot.adapterName`](https://github.com/github/hubot/pull/663) if you want to create an adapter-specific feature. 23 | - If you find yourself copying large blocks of code, consider refactoring it to be a bit more [DRY](https://en.wikipedia.org/wiki/Don't_repeat_yourself). 24 | - `robot.logger.debug` and `robot.logger.error` are helpful methods. You can set your `HUBOT_LOG_LEVEL` locally to see the output of these methods as your code is run. 25 | - If you have something super custom (say, wanting to prefix every command with "hey Siri ..."), it is totally fine to fork this repository and _not_ submit back a Pull Request. You can include your forked version in Hubot by specifying the repository URL in the version field in `package.json`. 26 | 27 | ## Thank You! 28 | 29 | You are super awesome for taking the time to contribute to Open Source Software like this project. :heart: 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Stephen Yeargin 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 for Hubot 2 | 3 | [![npm version](https://badge.fury.io/js/hubot-grafana.svg)](http://badge.fury.io/js/hubot-grafana) [![Node CI](https://github.com/stephenyeargin/hubot-grafana/actions/workflows/nodejs.yml/badge.svg)](https://github.com/stephenyeargin/hubot-grafana/actions/workflows/nodejs.yml) 4 | 5 | Use Hubot to query Grafana dashboards. Inspired by the work of [`hubot-graphite`](https://github.com/github/hubot-scripts/blob/master/src/scripts/graphite.coffee) and [`hubot-graphme`](https://github.com/rick/hubot-graphme). 6 | 7 | **Note:** This package requires Grafana 5 or higher. If you need support for an older version, use the `v2.x` releases. As of version `3.x` you must use the `UID` of a given dashboard because of a change in the Grafana API. We strongly recommend using the [`hubot-alias`](https://www.npmjs.com/package/hubot-alias) to save some time in making common requests. 8 | 9 | ## Installation 10 | 11 | In the Hubot project repo, run: 12 | 13 | `npm install hubot-grafana --save` 14 | 15 | Then add **hubot-grafana** to your `external-scripts.json`: 16 | 17 | ```json 18 | [ 19 | "hubot-grafana" 20 | ] 21 | ``` 22 | 23 | ## Configuration Variables 24 | 25 | ### General Settings 26 | 27 | | Configuration Variable | Required | Description | 28 | | --------------------------------- | -------- | ------------------------------ | 29 | | `HUBOT_GRAFANA_HOST` | **Yes^** | Host for your Grafana 2.x install, e.g. `https://play.grafana.org` | 30 | | `HUBOT_GRAFANA_API_KEY` | _Yes^^_ | Grafana API key (This can be "Viewer" role.) | 31 | | `HUBOT_GRAFANA_PER_ROOM` | No | Allow per room Grafana Host & API key configuration | 32 | | `HUBOT_GRAFANA_QUERY_TIME_RANGE` | No | Default time range for queries (defaults to 6h) | 33 | | `HUBOT_GRAFANA_DEFAULT_WIDTH` | No | Default width for rendered images (defaults to 1000) | 34 | | `HUBOT_GRAFANA_DEFAULT_HEIGHT` | No | Default height for rendered images (defaults to 500) | 35 | | `HUBOT_GRAFANA_DEFAULT_TIME_ZONE` | No | Default time zone for rendered images (defaults to `""`) | 36 | | `HUBOT_GRAFANA_ORG_ID` | No | Default organization id, need for image rendering in new versions of Grafana (defaults to `""`) | 37 | | `HUBOT_GRAFANA_API_ENDPOINT` | No | Default rendering api endpoint, need for image rendering in new versions of Grafana (defaults to `d-solo`) | 38 | | `HUBOT_GRAFANA_USE_THREADS` | No | When set to any value, graphs are sent in thread instead of as new message (only Slack supports this for now). | 39 | | `HUBOT_GRAFANA_MAX_RETURNED_DASHBOARDS` | No | Count of dashboards to return to prevent chat flood (defaults to `25`) | 40 | 41 | ^ _Not required when `HUBOT_GRAFANA_PER_ROOM` is set to 1._ 42 | 43 | ^^ _Not required for `auth.anonymous` Grafana configurations. All other authentication models will require a user-specific API key._ 44 | 45 | ### Image Hosting Configuration 46 | 47 | By default, *hubot-grafana* will assume you intend to render the image, unauthenticated, directly from your Grafana instance. The limitation is that you will only receive a link to those images, but they won't appear as images in most circumstances in your chat client. 48 | 49 | You can use one of the following strategies to host the generated images from Grafana. 50 | 51 | - [Amazon S3](https://github.com/stephenyeargin/hubot-grafana/wiki/Amazon-S3-Image-Hosting) 52 | - [Slack](https://github.com/stephenyeargin/hubot-grafana/wiki/Slack-Image-Hosting) 53 | - [Rocket.Chat](https://github.com/stephenyeargin/hubot-grafana/wiki/Rocket.Chat-Image-Hosting) 54 | - [Telegram](https://github.com/stephenyeargin/hubot-grafana/wiki/Telegram-Image-Hosting) 55 | 56 | ### Example Configuration 57 | 58 | ``` 59 | export HUBOT_GRAFANA_HOST=https://play.grafana.org 60 | export HUBOT_GRAFANA_API_KEY=abcd01234deadbeef01234 61 | export HUBOT_GRAFANA_QUERY_TIME_RANGE=1h 62 | export HUBOT_GRAFANA_S3_BUCKET=mybucket 63 | export HUBOT_GRAFANA_S3_BUCKET_REGION=us-east-1 64 | export HUBOT_GRAFANA_S3_PREFIX=graphs 65 | ``` 66 | 67 | ## Sample Interaction 68 | 69 | ``` 70 | user1>> hubot graf db 000000011 71 | hubot>> Graphite Carbon Metrics: https://play.grafana.org/render/d-solo/000000011/graphite-carbon-metrics/?panelId=1&width=1000&height=500&from=now-6h - https://play.grafana.org/d/000000011/graphite-carbon-metrics?panelId=1&fullscreen&from=now-6h 72 | ``` 73 | 74 | ## Grafana Commands 75 | 76 | ### Retrieve a Dashboard 77 | 78 | Get all panels in a dashboard. In this example, `000000011` is the UID for the given dashboard. To obtain the UID, use `hubot graf list` or `hubot graf search `. 79 | 80 | ``` 81 | hubot graf db 000000011 82 | ``` 83 | 84 | ### Retrieve Specific Panels 85 | 86 | Get a single panel of a dashboard. In this example, only the third panel would be returned. Note that this is the _visual_ panel ID, counted from top to bottom, left to right, rather than the unique identifier stored with the panel itself. 87 | 88 | ``` 89 | hubot graf db 000000011:3 90 | ``` 91 | 92 | If you want to refer to the API Panel ID, use the `:panel-` format to retrieve it. These will not change when the dashboard is re-arranged. 93 | 94 | ``` 95 | hubot graf db 000000011:panel-8 96 | ``` 97 | 98 | Get all panels matching a particular title (case insensitive) in a dashboard. In this case, only panels containing `cpu` would be returned. 99 | 100 | ``` 101 | hubot graf db 000000011:cpu 102 | ``` 103 | 104 | ### Retrieve Dashboards in a Time Window 105 | 106 | Specify a time range for the dashboard. In this example, the dashboard would be set to display data from 12 hours ago to now. 107 | 108 | ``` 109 | hubot graf db 000000011 now-12hr 110 | ``` 111 | 112 | If you don't want to show the dashboard uptil now, you can add an extra time specification, which will be the `to` time slot. In this example, the dashboard would be set to display data from 24 hours ago to 12 hours ago. 113 | 114 | ``` 115 | hubot graf db 000000011 now-24hr now-12hr 116 | ``` 117 | 118 | You can combine multiple commands in this format as well. In this example, retrieve only the third panel of the `graphite-carbon-metrics` dashboard with a window of eight days ago to yesterday. 119 | 120 | ``` 121 | hubot graf db 000000011:3 now-8d now-1d 122 | ``` 123 | 124 | ### Templated Dashboards 125 | 126 | Grafana allows dashboards to be set up as templates and accept arguments to generate them through the API. In this example, we get a templated dashboard with the `$host` parameter set to `carbon-a` 127 | 128 | ``` 129 | hubot graf db 000000011 host=carbon-a 130 | ``` 131 | 132 | ### Utility Commands 133 | 134 | This command retrieves all dashboards and their slugs so they can be used in other commands. 135 | 136 | ``` 137 | hubot graf list 138 | ``` 139 | 140 | Dashboards can be tagged for easier reference. In this example, return all dashboards tagged with `production`. 141 | 142 | ``` 143 | hubot graf list production 144 | ``` 145 | 146 | Similarly, you can search the list of dashboards. In this example, return all dashboards that contain the word `elb`. 147 | 148 | ``` 149 | hubot graf search elb 150 | ``` 151 | 152 | ### Per room configuration 153 | 154 | When `HUBOT_GRAFANA_PER_ROOM` is set to '1' the following commands configure the Grafana Host and API key for the room in which the commands are issued. 155 | 156 | ``` 157 | hubot graf set host https://play.grafana.org 158 | hubot graf set api_key abcd01234deadbeef01234 159 | ``` 160 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | module.exports = function (robot) { 5 | const scriptsPath = path.resolve(__dirname, 'src'); 6 | if (fs.existsSync(scriptsPath)) { 7 | let scripts = ['grafana.js'].sort(); 8 | for (let script of scripts) { 9 | robot.loadFile(scriptsPath, script); 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubot-grafana", 3 | "description": "Query Grafana dashboards", 4 | "version": "7.0.5", 5 | "author": "Stephen Yeargin ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "hubot", 9 | "hubot-scripts", 10 | "grafana", 11 | "graphs" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/stephenyeargin/hubot-grafana.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/stephenyeargin/hubot-grafana/issues" 19 | }, 20 | "dependencies": { 21 | "@aws-sdk/client-s3": "^3.565.0", 22 | "node-fetch": "^2.7.0" 23 | }, 24 | "peerDependencies": { 25 | "hubot": ">=3 || 0.0.0-development" 26 | }, 27 | "devDependencies": { 28 | "@types/hubot": "^3.3.7", 29 | "chai": "^4.4.1", 30 | "eslint": "^8.45.0", 31 | "eslint-config-airbnb-base": "^15.0.0", 32 | "eslint-plugin-import": "^2.27.5", 33 | "hubot": "^7.0.0", 34 | "hubot-mock-adapter": "^2.0.0", 35 | "husky": "^9.0.11", 36 | "mocha": "^10.4.0", 37 | "nock": "^13.5.4", 38 | "nyc": "^15.1.0" 39 | }, 40 | "main": "index.js", 41 | "scripts": { 42 | "test": "mocha", 43 | "test-with-coverage": "nyc --reporter=text mocha", 44 | "bootstrap": "script/bootstrap", 45 | "prepare": "husky", 46 | "lint": "eslint src/ test/" 47 | }, 48 | "files": [ 49 | "src/**/*.js", 50 | "CONTRIBUTING.md", 51 | "LICENSE", 52 | "index.js", 53 | "types.d.ts" 54 | ], 55 | "volta": { 56 | "node": "18.19.0" 57 | }, 58 | "types": "types.d.ts" 59 | } 60 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Make sure everything is development forever 4 | export NODE_ENV=development 5 | 6 | # Load environment specific environment variables 7 | if [ -f .env ]; then 8 | source .env 9 | fi 10 | 11 | if [ -f .env.${NODE_ENV} ]; then 12 | source .env.${NODE_ENV} 13 | fi 14 | 15 | npm ci 16 | -------------------------------------------------------------------------------- /src/Bot.js: -------------------------------------------------------------------------------- 1 | const { Adapter } = require('./adapters/Adapter'); 2 | const { GrafanaService } = require('./service/GrafanaService'); 3 | const { GrafanaClient } = require('./grafana-client'); 4 | 5 | /** 6 | * The bot brings the Adapter and the Grafana Service together. 7 | * It can be used for uploading charts and sending responses out. 8 | */ 9 | class Bot { 10 | /** 11 | * Represents the Bot class. 12 | * @constructor 13 | * @param {Hubot.Robot} robot - The robot instance. 14 | */ 15 | constructor(robot) { 16 | /** @type {Adapter} */ 17 | this.adapter = new Adapter(robot); 18 | 19 | /** @type {Hubot.Log} */ 20 | this.logger = robot.logger; 21 | } 22 | 23 | /** 24 | * Creates a new Grafana service based on the provided message. 25 | * @param {Hubot.Response} context - The context object. 26 | * @returns {GrafanaService|null} - The created Grafana service or null if the client is not available. 27 | */ 28 | createService(context) { 29 | 30 | const robot = context.robot; 31 | let host = process.env.HUBOT_GRAFANA_HOST; 32 | let apiKey = process.env.HUBOT_GRAFANA_API_KEY; 33 | 34 | if (process.env.HUBOT_GRAFANA_PER_ROOM === '1') { 35 | const room = this.getRoom(context); 36 | host = robot.brain.get(`grafana_host_${room}`); 37 | apiKey = robot.brain.get(`grafana_api_key_${room}`); 38 | } 39 | 40 | if (host == null) { 41 | this.sendError('No Grafana endpoint configured.', context); 42 | return null; 43 | } 44 | 45 | let client = new GrafanaClient(robot.logger, host, apiKey); 46 | return new GrafanaService(client); 47 | } 48 | 49 | /** 50 | * Sends dashboard charts based on a request string. 51 | * 52 | * @param {Hubot.Response} context - The context object. 53 | * @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). 54 | * @param {number} maxReturnDashboards - The maximum number of dashboards to return. 55 | * @returns {Promise} - A promise that resolves when the charts are sent. 56 | */ 57 | async sendDashboardChartFromString(context, requestString, maxReturnDashboards = null) { 58 | const service = this.createService(context); 59 | if (service == null) return; 60 | 61 | const req = service.parseToGrafanaDashboardRequest(requestString); 62 | const dashboard = await service.getDashboard(req.uid); 63 | 64 | // Check dashboard information 65 | if (!dashboard) { 66 | return this.sendError('An error ocurred. Check your logs for more details.', context); 67 | } 68 | 69 | if (dashboard.message) { 70 | return this.sendError(dashboard.message, context); 71 | } 72 | 73 | // Defaults 74 | const data = dashboard.dashboard; 75 | 76 | // Handle empty dashboard 77 | if (data.rows == null) { 78 | return this.sendError('Dashboard empty.', context); 79 | } 80 | 81 | maxReturnDashboards = maxReturnDashboards || parseInt(process.env.HUBOT_GRAFANA_MAX_RETURNED_DASHBOARDS, 10) || 25; 82 | const charts = await service.getDashboardCharts(req, dashboard, maxReturnDashboards); 83 | if (charts == null || charts.length === 0) { 84 | return this.sendError('Could not locate desired panel.', context); 85 | } 86 | 87 | for (let chart of charts) { 88 | await this.sendDashboardChart(context, chart); 89 | } 90 | } 91 | 92 | /** 93 | * Sends a dashboard chart. 94 | * 95 | * @param {Hubot.Response} context - The context object. 96 | * @param {DashboardChart} dashboard - The dashboard object. 97 | * @returns {Promise} - A promise that resolves when the chart is sent. 98 | */ 99 | async sendDashboardChart(context, dashboard) { 100 | if (!this.adapter.isUploadSupported()) { 101 | this.adapter.responder.send(context, dashboard.title, dashboard.imageUrl, dashboard.grafanaChartLink); 102 | return; 103 | } 104 | 105 | const service = this.createService(context); 106 | if (service == null) return; 107 | 108 | /** @type {DownloadedFile|null} */ 109 | let file = null; 110 | 111 | try { 112 | file = await service.client.download(dashboard.imageUrl); 113 | } catch (err) { 114 | return this.sendError(err, context); 115 | } 116 | 117 | this.logger.debug(`Uploading file: ${file.body.length} bytes, content-type[${file.contentType}]`); 118 | this.adapter.uploader.upload(context, dashboard.title || 'Image', file, dashboard.grafanaChartLink); 119 | } 120 | 121 | /** 122 | * *Sends an error message. 123 | * @param {string} message the error message. 124 | * @param {Hubot.Response} context The context. 125 | */ 126 | sendError(message, context) { 127 | context.robot.logger.error(message); 128 | this.adapter.responder.sendError(context, message); 129 | } 130 | 131 | /** 132 | * Gets the room from the context. 133 | * @param {Hubot.Response} context The context. 134 | * @returns {string} 135 | */ 136 | getRoom(context) { 137 | // placeholder for further adapter support (i.e. MS Teams) as then room also 138 | // contains thread conversation id 139 | return context.envelope.room; 140 | } 141 | } 142 | 143 | exports.Bot = Bot; 144 | -------------------------------------------------------------------------------- /src/adapters/Adapter.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | 3 | const { Responder } = require('./Responder'); 4 | const { SlackResponder } = require('./implementations/SlackResponder'); 5 | const { BearyChatResponder } = require('./implementations/BearyChatResponder'); 6 | const { HipChatResponder } = require('./implementations/HipChatResponder'); 7 | 8 | const { Uploader } = require('./Uploader'); 9 | const { S3Uploader } = require('./implementations/S3Uploader'); 10 | const { RocketChatUploader } = require('./implementations/RocketChatUploader'); 11 | const { TelegramUploader } = require('./implementations/TelegramUploader'); 12 | const { SlackUploader } = require('./implementations/SlackUploader'); 13 | 14 | /** 15 | * The override responder is used to override the default responder. 16 | * This can be used to inject a custom responder to influence the message formatting. 17 | * @type {Responder|null} 18 | */ 19 | let overrideResponder = null; 20 | 21 | /** 22 | * The Adapter will hide away platform specific details for file upload and 23 | * response messages. When an S3 bucket is configured, it will always take 24 | * precedence. 25 | */ 26 | class Adapter { 27 | /** 28 | * @param {Hubot.Robot} robot The robot -- TODO: let's see if we can refactor this one out. 29 | */ 30 | constructor(robot) { 31 | /** @type {Hubot.robot} */ 32 | this.robot = robot; 33 | 34 | /** @type {string} */ 35 | this.s3_bucket = process.env.HUBOT_GRAFANA_S3_BUCKET; 36 | } 37 | 38 | /** 39 | * The site defines where the file should be uploaded to. 40 | */ 41 | get site() { 42 | // prioritize S3 if configured 43 | if (this.s3_bucket) { 44 | return 's3'; 45 | } 46 | if (/slack/i.test(this.robot.adapterName)) { 47 | return 'slack'; 48 | } 49 | if (/rocketchat/i.test(this.robot.adapterName)) { 50 | return 'rocketchat'; 51 | } 52 | if (/telegram/i.test(this.robot.adapterName)) { 53 | return 'telegram'; 54 | } 55 | return ''; 56 | } 57 | 58 | /** 59 | * The responder is responsible for sending a (platform specific) response. 60 | */ 61 | /** @type {Responder} */ 62 | get responder() { 63 | 64 | if(overrideResponder){ 65 | return overrideResponder; 66 | } 67 | 68 | if (/slack/i.test(this.robot.adapterName)) { 69 | return new SlackResponder(); 70 | } 71 | if (/hipchat/i.test(this.robot.adapterName)) { 72 | return new HipChatResponder(); 73 | } 74 | if (/bearychat/i.test(this.robot.adapterName)) { 75 | return new BearyChatResponder(); 76 | } 77 | 78 | return new Responder(); 79 | } 80 | 81 | /** 82 | * The responder is responsible for doing a (platform specific) upload. 83 | * If an upload is not supported, the method will throw an error. 84 | */ 85 | /** @type {Uploader} */ 86 | get uploader() { 87 | switch (this.site) { 88 | case 's3': 89 | return new S3Uploader(this.responder, this.robot.logger); 90 | case 'rocketchat': 91 | return new RocketChatUploader(this.robot, this.robot.logger); 92 | case 'slack': 93 | return new SlackUploader(this.robot, this.robot.logger); 94 | case 'telegram': 95 | return new TelegramUploader(); 96 | } 97 | 98 | throw new Error(`Upload not supported for '${this.robot.adapterName}'`); 99 | } 100 | 101 | /** 102 | * Indicates if an upload is supported. 103 | * @returns {boolean} 104 | */ 105 | isUploadSupported() { 106 | return this.site !== ''; 107 | } 108 | } 109 | 110 | /** 111 | * Overrides the responder. 112 | * @param {Responder} responder The responder to use. 113 | */ 114 | exports.setResponder = function(responder) { 115 | overrideResponder = responder; 116 | } 117 | 118 | /** 119 | * Clears the override responder. 120 | */ 121 | exports.clearResponder = function() { 122 | overrideResponder = null; 123 | } 124 | 125 | exports.Adapter = Adapter; 126 | -------------------------------------------------------------------------------- /src/adapters/Responder.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | class Responder { 3 | /** 4 | * Sends the response to Hubot. 5 | * @param {Hubot.Response} res the context. 6 | * @param {string} title the title of the message 7 | * @param {string} image the URL of the image 8 | * @param {string} link the title of the link 9 | */ 10 | send(res, title, image, link) { 11 | res.send(`${title}: ${image} - ${link}`); 12 | } 13 | 14 | /** 15 | * Sends the error message to Hubot. 16 | * @param {Hubot.Response} res the context. 17 | * @param {string} message the error message. 18 | */ 19 | sendError(res, message) { 20 | res.send(message); 21 | } 22 | } 23 | 24 | exports.Responder = Responder; 25 | -------------------------------------------------------------------------------- /src/adapters/Uploader.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | class Uploader { 3 | 4 | /** 5 | * Uploads the a screenshot of the dashboards. 6 | * 7 | * @param {Hubot.Response} res the context 8 | * @param {string} title the title of the dashboard. 9 | * @param {({ body: Buffer, contentType: string})} file request for getting the screenshot. 10 | * @param {string} grafanaChartLink link to the Grafana chart. 11 | */ 12 | upload(res, title, file, grafanaChartLink) { 13 | throw new Error('Not supported'); 14 | } 15 | } 16 | exports.Uploader = Uploader; 17 | -------------------------------------------------------------------------------- /src/adapters/implementations/BearyChatResponder.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | const { Responder } = require('../Responder'); 3 | 4 | class BearyChatResponder extends Responder { 5 | /** 6 | * 7 | * @param {Hubot.Robot} robot 8 | */ 9 | constructor(robot) { 10 | super(); 11 | 12 | /** @type {Hubot.Robot} */ 13 | this.robot = robot; 14 | } 15 | /** 16 | * Sends the response to Hubot. 17 | * @param {Hubot.Response} res the context. 18 | * @param {string} title the title of the message 19 | * @param {string} image the URL of the image 20 | * @param {string} link the title of the link 21 | */ 22 | send(res, title, image, link) { 23 | this.robot.emit('bearychat.attachment', { 24 | message: { 25 | room: res.envelope.room, 26 | }, 27 | text: `[${title}](${link})`, 28 | attachments: [ 29 | { 30 | fallback: `${title}: ${image} - ${link}`, 31 | images: [{ url: image }], 32 | }, 33 | ], 34 | }); 35 | } 36 | } 37 | 38 | exports.BearyChatResponder = BearyChatResponder; 39 | -------------------------------------------------------------------------------- /src/adapters/implementations/HipChatResponder.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | const { Responder } = require("../Responder"); 3 | 4 | class HipChatResponder extends Responder { 5 | /** 6 | * Sends the response to Hubot. 7 | * @param {Hubot.Response} res the context. 8 | * @param {string} title the title of the message 9 | * @param {string} image the URL of the image 10 | * @param {string} link the title of the link 11 | */ 12 | send(res, title, image, link) { 13 | res.send(`${title}: ${link} - ${image}`); 14 | } 15 | } 16 | 17 | exports.HipChatResponder = HipChatResponder; 18 | -------------------------------------------------------------------------------- /src/adapters/implementations/RocketChatUploader.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | const { Uploader } = require('../Uploader'); 3 | 4 | class RocketChatUploader extends Uploader { 5 | /** 6 | * Creates a new instance. 7 | * @param {Hubot.Robot} robot the robot, TODO: let's see if we can refactor it out! 8 | * @param {Hubot.Log} logger the logger 9 | */ 10 | constructor(robot, logger) { 11 | super(); 12 | 13 | /** @type {string} */ 14 | this.rocketchat_user = process.env.ROCKETCHAT_USER; 15 | 16 | /** @type {string} */ 17 | this.rocketchat_password = process.env.ROCKETCHAT_PASSWORD; 18 | 19 | /** @type {string} */ 20 | this.rocketchat_url = process.env.ROCKETCHAT_URL; 21 | 22 | if ( 23 | this.rocketchat_url && 24 | !this.rocketchat_url.startsWith('http://') && 25 | !this.rocketchat_url.startsWith('https://') 26 | ) { 27 | this.rocketchat_url = `http://${rocketchat_url}`; 28 | } 29 | 30 | /** @type {Hubot.Robot} */ 31 | this.robot = robot; 32 | 33 | /** @type {Hubot.Log} */ 34 | this.logger = logger; 35 | } 36 | 37 | /** 38 | * Logs in to the RocketChat API using the provided credentials. 39 | * @returns {Promise<{'X-Auth-Token': string, 'X-User-Id': string}>} A promise that resolves to the authentication headers if successful. 40 | * @throws {Error} If authentication fails. 41 | */ 42 | async login() { 43 | const authUrl = `${this.rocketchat_url}/api/v1/login`; 44 | const authForm = { 45 | username: this.rocketchat_user, 46 | password: this.rocketchat_password, 47 | }; 48 | 49 | let rocketchatResBodyJson = null; 50 | 51 | try { 52 | rocketchatResBodyJson = await post(authUrl, authForm); 53 | } catch (err) { 54 | this.logger.error(err); 55 | throw new Error('Could not authenticate.'); 56 | } 57 | 58 | const { status } = rocketchatResBodyJson; 59 | if (status === 'success') { 60 | return { 61 | 'X-Auth-Token': rocketchatResBodyJson.data.authToken, 62 | 'X-User-Id': rocketchatResBodyJson.data.userId, 63 | }; 64 | } 65 | 66 | const errMsg = rocketchatResBodyJson.message; 67 | this.logger.error(errMsg); 68 | throw new Error(errMsg); 69 | } 70 | 71 | /** 72 | * Uploads the a screenshot of the dashboards. 73 | * 74 | * @param {Hubot.Response} res the context. 75 | * @param {string} title the title of the dashboard. 76 | * @param {{ body: Buffer, contentType: string}=>void} file the screenshot. 77 | * @param {string} grafanaChartLink link to the Grafana chart. 78 | */ 79 | async upload(res, title, file, grafanaChartLink) { 80 | let authHeaders = null; 81 | try { 82 | authHeaders = await this.login(); 83 | } catch (ex) { 84 | let msg = ex == 'Could not authenticate.' ? "invalid url, user or password/can't access rocketchat api" : ex; 85 | res.send(`${title} - [Rocketchat auth Error - ${msg}] - ${grafanaChartLink}`); 86 | return; 87 | } 88 | 89 | // fill in the POST request. This must be www-form/multipart 90 | // TODO: needs some extra testing! 91 | const uploadUrl = `${this.rocketchat_url}/api/v1/rooms.upload/${res.envelope.user.roomID}`; 92 | const uploadForm = { 93 | msg: `${title}: ${grafanaChartLink}`, 94 | // grafanaDashboardRequest() is the method that downloads the .png 95 | file: { 96 | value: file.body, 97 | options: { 98 | filename: `${title} ${Date()}.png`, 99 | contentType: 'image/png', 100 | }, 101 | }, 102 | }; 103 | 104 | let body = null; 105 | 106 | try { 107 | body = await this.post(uploadUrl, uploadForm, authHeaders); 108 | } catch (err) { 109 | this.logger.error(err); 110 | res.send(`${title} - [Upload Error] - ${grafanaChartLink}`); 111 | return; 112 | } 113 | 114 | if (!body.success) { 115 | this.logger.error(`rocketchat service error while posting data:${body.error}`); 116 | return res.send(`${title} - [Form Error: can't upload file : ${body.error}] - ${grafanaChartLink}`); 117 | } 118 | } 119 | 120 | /** 121 | * Posts the data data to the specified url and returns JSON. 122 | * @param {string} url - the URL 123 | * @param {Record} formData - formatData 124 | * @param {Record|null} headers - formatData 125 | * @returns {Promise} The deserialized JSON response or an error if something went wrong. 126 | */ 127 | async post(url, formData, headers = null) { 128 | const response = await fetch(url, { 129 | method: 'POST', 130 | headers: headers, 131 | body: new FormData(formData), 132 | }); 133 | 134 | if (!response.ok) { 135 | throw new Error('HTTP request failed'); 136 | } 137 | 138 | const data = await response.json(); 139 | return data; 140 | } 141 | } 142 | 143 | exports.RocketChatUploader = RocketChatUploader; 144 | -------------------------------------------------------------------------------- /src/adapters/implementations/S3Uploader.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | 3 | const crypto = require('crypto'); 4 | const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); 5 | const { Uploader } = require('../Uploader'); 6 | const { Responder } = require('../Responder'); 7 | 8 | class S3Uploader extends Uploader { 9 | /** 10 | * Creates a new instance. 11 | * 12 | * @param {Responder} responder the responder, called when the upload completes 13 | * @param {Hubot.Log} logger the logger 14 | */ 15 | constructor(responder, logger) { 16 | super(); 17 | 18 | /** @type {Responder} */ 19 | this.responder = responder; 20 | 21 | /** @type {Hubot.Log} */ 22 | this.logger = logger; 23 | 24 | /** @type {string} */ 25 | this.s3_bucket = process.env.HUBOT_GRAFANA_S3_BUCKET; 26 | 27 | /** @type {string} */ 28 | this.s3_prefix = process.env.HUBOT_GRAFANA_S3_PREFIX; 29 | 30 | /** @type {string} */ 31 | this.s3_region = process.env.HUBOT_GRAFANA_S3_REGION || process.env.AWS_REGION || 'us-standard'; 32 | } 33 | 34 | /** 35 | * Uploads the a screenshot of the dashboards. 36 | * 37 | * @param {Hubot.Response} res the context. 38 | * @param {string} title the title of the dashboard. 39 | * @param {{ body: Buffer, contentType: string}} file request for getting the screenshot. 40 | * @param {string} grafanaChartLink link to the Grafana chart. 41 | */ 42 | upload(res, title, file, grafanaChartLink) { 43 | // Pick a random filename 44 | const prefix = this.s3_prefix || 'grafana'; 45 | const uploadPath = `${prefix}/${crypto.randomBytes(20).toString('hex')}.png`; 46 | 47 | const s3 = new S3Client({ 48 | apiVersion: '2006-03-01', 49 | region: this.s3_region, 50 | }); 51 | 52 | const params = { 53 | Bucket: this.s3_bucket, 54 | Key: uploadPath, 55 | Body: file.body, 56 | ACL: 'public-read', 57 | ContentLength: file.body.length, 58 | ContentType: file.contentType, 59 | }; 60 | const command = new PutObjectCommand(params); 61 | 62 | s3.send(command) 63 | .then(() => { 64 | this.responder.send(res, title, `https://${this.s3_bucket}.s3.${this.s3_region}.amazonaws.com/${params.Key}`, grafanaChartLink); 65 | }) 66 | .catch((s3Err) => { 67 | this.logger.error(`Upload Error Code: ${s3Err}`); 68 | res.send(`${title} - [Upload Error] - ${grafanaChartLink}`); 69 | }); 70 | } 71 | } 72 | exports.S3Uploader = S3Uploader; 73 | -------------------------------------------------------------------------------- /src/adapters/implementations/SlackResponder.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | const { Responder } = require("../Responder"); 3 | 4 | class SlackResponder extends Responder { 5 | constructor() { 6 | super(); 7 | 8 | /** @type {boolean} */ 9 | this.use_threads = process.env.HUBOT_GRAFANA_USE_THREADS || false; 10 | } 11 | 12 | /** 13 | * Sends the response to Hubot. 14 | * @param {Hubot.Response} res the context. 15 | * @param {string} title the title of the message 16 | * @param {string} imageUrl the URL of the image 17 | * @param {string} dashboardLink the title of the link 18 | */ 19 | send(res, title, imageUrl, dashboardLink) { 20 | 21 | let thread_ts = null 22 | if (this.use_threads) { 23 | thread_ts = res.message.rawMessage.ts; 24 | } 25 | 26 | res.send({ 27 | attachments: [ 28 | { 29 | fallback: `${title}: ${imageUrl} - ${dashboardLink}`, 30 | title, 31 | title_link: dashboardLink, 32 | image_url: imageUrl, 33 | }, 34 | ], 35 | unfurl_links: false, 36 | thread_ts: thread_ts 37 | }); 38 | } 39 | } 40 | 41 | exports.SlackResponder = SlackResponder; 42 | -------------------------------------------------------------------------------- /src/adapters/implementations/SlackUploader.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | 3 | const { Uploader } = require('../Uploader'); 4 | 5 | class SlackUploader extends Uploader { 6 | /** 7 | * 8 | * @param {Hubot.Robot} robot the robot, TODO: let's see if we can refactor it out! 9 | * @param {Hubot.Log} logger the logger 10 | */ 11 | constructor(robot, logger) { 12 | super(); 13 | 14 | /** @type {boolean} */ 15 | this.use_threads = process.env.HUBOT_GRAFANA_USE_THREADS || false; 16 | 17 | /** @type {Hubot.Robot} */ 18 | this.robot = robot; 19 | 20 | /** @type {Hubot.Log} */ 21 | this.logger = logger; 22 | 23 | } 24 | 25 | /** 26 | * Uploads the a screenshot of the dashboards. 27 | * 28 | * @param {Hubot.Response} res the context. 29 | * @param {string} title the title of the dashboard. 30 | * @param {{ body: Buffer, contentType: string}} file file with the screenshot. 31 | * @param {string} grafanaChartLink link to the Grafana chart. 32 | */ 33 | async upload(res, title, file, grafanaChartLink) { 34 | const thread_ts = this.use_threads ? res.message.rawMessage.ts : null; 35 | const channel = res.envelope.room; 36 | 37 | try { 38 | let options = { 39 | filename: title + '.png', 40 | file: Buffer.from(file.body), 41 | title: 'dashboard', 42 | initial_comment: `${title}: ${grafanaChartLink}`, 43 | thread_ts: thread_ts, 44 | channels: channel, 45 | }; 46 | 47 | await this.robot.adapter.client.web.files.uploadV2(options); 48 | } catch (err) { 49 | this.logger.error(err, 'SlackUploader.upload.uploadFile'); 50 | res.send(`${title} - [Slack files.upload Error: can't upload file] - ${grafanaChartLink}`); 51 | } 52 | } 53 | } 54 | 55 | exports.SlackUploader = SlackUploader; 56 | -------------------------------------------------------------------------------- /src/adapters/implementations/TelegramUploader.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | const { Uploader } = require('../Uploader'); 3 | 4 | class TelegramUploader extends Uploader { 5 | /** 6 | * Uploads the a screenshot of the dashboards. 7 | * 8 | * @param {Hubot.Response} res the context. 9 | * @param {string} title the title of the dashboard. 10 | * @param {{ body: Buffer, contentType: string}} file the screenshot. 11 | * @param {string} grafanaChartLink link to the Grafana chart. 12 | */ 13 | upload(res, title, file, grafanaChartLink) { 14 | const caption = `${title}: ${grafanaChartLink}`; 15 | 16 | // Check: https://github.com/lukefx/hubot-telegram/blob/master/src/TelegramMiddleware.ts#L19 17 | res.sendPhoto(res.envelope.room, file.body, { 18 | caption, 19 | }); 20 | } 21 | } 22 | exports.TelegramUploader = TelegramUploader; 23 | -------------------------------------------------------------------------------- /src/grafana-client.js: -------------------------------------------------------------------------------- 1 | 'strict'; 2 | const fetch = require('node-fetch'); 3 | const { URL, URLSearchParams } = require('url'); 4 | 5 | /// 6 | 7 | /** 8 | * If the given url does not have a host, it will add it to the 9 | * url and return it. 10 | * @param {string} url the url 11 | * @returns {string} the expanded URL. 12 | */ 13 | function expandUrl(url, host) { 14 | 15 | if (url.startsWith('http://') || url.startsWith('https://')) { 16 | return url; 17 | } 18 | 19 | if (!host) { 20 | throw new Error('No Grafana endpoint configured.'); 21 | } 22 | 23 | let apiUrl = host; 24 | if (!apiUrl.endsWith('/')) { 25 | apiUrl += '/'; 26 | } 27 | 28 | apiUrl += 'api/'; 29 | apiUrl += url; 30 | 31 | return apiUrl; 32 | } 33 | 34 | class GrafanaClient { 35 | /** 36 | * Creates a new instance. 37 | * @param {Hubot.Log} logger the logger. 38 | * @param {string} host the host. 39 | * @param {string} apiKey the api key. 40 | */ 41 | constructor(logger, host, apiKey) { 42 | /** 43 | * The logger. 44 | * @type {Hubot.Log} 45 | */ 46 | this.logger = logger; 47 | 48 | /** 49 | * The host. 50 | * @type {string | null} 51 | */ 52 | this.host = host; 53 | 54 | /** 55 | * The API key. 56 | * @type {string | null} 57 | */ 58 | this.apiKey = apiKey; 59 | } 60 | 61 | /** 62 | * Performs a GET on the Grafana API. 63 | * Remarks: uses Hubot because of Nock testing. 64 | * @param {string} url the url 65 | * @returns {Promise} the response data 66 | */ 67 | async get(url) { 68 | const fullUrl = expandUrl(url, this.host); 69 | const response = await fetch(fullUrl, { 70 | method: 'GET', 71 | headers: grafanaHeaders(null, false, this.apiKey), 72 | }); 73 | 74 | await this.throwIfNotOk(response); 75 | 76 | const json = await response.json(); 77 | return json; 78 | } 79 | 80 | /** 81 | * Performs a POST call to the Grafana API. 82 | * 83 | * @param {string} url The API sub URL 84 | * @param {Record} data The data that will be sent. 85 | * @returns {Promise} 86 | */ 87 | async post(url, data) { 88 | const fullUrl = expandUrl(url, this.host); 89 | const response = await fetch(fullUrl, { 90 | method: 'POST', 91 | headers: grafanaHeaders('application/json', false, this.apiKey), 92 | body: JSON.stringify(data), 93 | }); 94 | 95 | await this.throwIfNotOk(response); 96 | 97 | const json = await response.json(); 98 | return json; 99 | } 100 | 101 | /** 102 | * Ensures that the response is OK. If the response is not OK, an error is thrown. 103 | * @param {fetch.Response} response - The response object. 104 | * @throws {Error} If the response is not OK, an error with the response text is thrown. 105 | */ 106 | async throwIfNotOk(response) { 107 | if (response.ok) { 108 | return; 109 | } 110 | 111 | let contentType = null; 112 | if (response.headers.has('content-type')) { 113 | contentType = response.headers.get('content-type'); 114 | if (contentType.includes(';')) { 115 | contentType = contentType.split(';')[0]; 116 | } 117 | } 118 | 119 | if (contentType == 'application/json') { 120 | const json = await response.json(); 121 | const error = new Error(json.message || 'Error while fetching data from Grafana.'); 122 | error.data = json; 123 | throw error; 124 | } 125 | 126 | let error = new Error('Error while fetching data from Grafana.'); 127 | if (contentType != 'text/html') { 128 | error.data = await response.text(); 129 | } 130 | 131 | throw error; 132 | } 133 | 134 | /** 135 | * Downloads the given URL. 136 | * @param {string} url The URL. 137 | * @returns {Promise} 138 | */ 139 | async download(url) { 140 | let response = await fetch(url, { 141 | method: 'GET', 142 | headers: grafanaHeaders(null, null, this.apiKey), 143 | }); 144 | 145 | await this.throwIfNotOk(response); 146 | 147 | const contentType = response.headers.get('content-type'); 148 | const body = await response.arrayBuffer(); 149 | 150 | return { 151 | body: Buffer.from(body), 152 | contentType: contentType, 153 | }; 154 | } 155 | 156 | createGrafanaChartLink(query, uid, panel, timeSpan, variables) { 157 | const url = new URL(`${this.host}/d/${uid}/`); 158 | 159 | if (panel) { 160 | url.searchParams.set('panelId', panel.id); 161 | url.searchParams.set('fullscreen', ''); 162 | } 163 | 164 | url.searchParams.set('from', timeSpan.from); 165 | url.searchParams.set('to', timeSpan.to); 166 | 167 | if (variables) { 168 | const additionalParams = new URLSearchParams(variables); 169 | for (const [key, value] of additionalParams) { 170 | url.searchParams.append(key, value); 171 | } 172 | } 173 | 174 | // TODO: should we add these? 175 | // if (query.tz) { 176 | // url.searchParams.set('tz', query.tz); 177 | // } 178 | // if (query.orgId) { 179 | // url.searchParams.set('orgId', query.orgId); 180 | // } 181 | 182 | return url.toString().replace('fullscreen=&', 'fullscreen&'); 183 | } 184 | 185 | createImageUrl(query, uid, panel, timeSpan, variables) { 186 | const url = new URL(`${this.host}/render/${query.apiEndpoint}/${uid}/`); 187 | 188 | if (panel) { 189 | url.searchParams.set('panelId', panel.id); 190 | } else if (query.kiosk) { 191 | url.searchParams.set('kiosk', ''); 192 | url.searchParams.set('autofitpanels', ''); 193 | } 194 | 195 | url.searchParams.set('width', query.width); 196 | url.searchParams.set('height', query.height); 197 | url.searchParams.set('from', timeSpan.from); 198 | url.searchParams.set('to', timeSpan.to); 199 | 200 | if (variables) { 201 | const additionalParams = new URLSearchParams(variables); 202 | for (const [key, value] of additionalParams) { 203 | url.searchParams.append(key, value); 204 | } 205 | } 206 | 207 | if (query.tz) { 208 | url.searchParams.set('tz', query.tz); 209 | } 210 | 211 | //TODO: currently not tested 212 | if (query.orgId) { 213 | url.searchParams.set('orgId', query.orgId); 214 | } 215 | 216 | return url.toString().replace('kiosk=&', 'kiosk&').replace('autofitpanels=&', 'autofitpanels&'); 217 | } 218 | } 219 | 220 | /** 221 | * Create headers for the Grafana request. 222 | * @param {string | null} contentType Indicates if the HTTP client should post. 223 | * @param {string | false} encoding Indicates if an encoding should be set. 224 | * @param {string | null} api_key The API key. 225 | * @returns {Record} 226 | */ 227 | function grafanaHeaders(contentType, encoding, api_key) { 228 | const headers = { Accept: 'application/json' }; 229 | 230 | if (contentType) { 231 | headers['Content-Type'] = contentType; 232 | } 233 | 234 | // download needs a null encoding 235 | // TODO: are we sure? 236 | if (encoding !== false) { 237 | headers['encoding'] = encoding; 238 | } 239 | 240 | if (api_key) { 241 | headers.Authorization = `Bearer ${api_key}`; 242 | } 243 | 244 | return headers; 245 | } 246 | 247 | exports.GrafanaClient = GrafanaClient; 248 | -------------------------------------------------------------------------------- /src/grafana.js: -------------------------------------------------------------------------------- 1 | // Description: 2 | // Query Grafana dashboards 3 | // 4 | // Examples: 5 | // - `hubot graf db graphite-carbon-metrics` - Get all panels in the dashboard 6 | // - `hubot graf db graphite-carbon-metrics:3` - Get only the third panel, from left to right, of a particular dashboard 7 | // - `hubot graf db graphite-carbon-metrics:3 width=1000` - Get only the third panel, from left to right, of a particular dashboard. Set the image width to 1000px 8 | // - `hubot graf db graphite-carbon-metrics:3 height=2000` - Get only the third panel, from left to right, of a particular dashboard. Set the image height to 2000px 9 | // - `hubot graf db graphite-carbon-metrics:panel-8` - Get only the panel of a particular dashboard with the ID of 8 10 | // - `hubot graf db graphite-carbon-metrics:cpu` - Get only the panels containing "cpu" (case insensitive) in the title 11 | // - `hubot graf db graphite-carbon-metrics now-12hr` - Get a dashboard with a window of 12 hours ago to now 12 | // - `hubot graf db graphite-carbon-metrics now-24hr now-12hr` - Get a dashboard with a window of 24 hours ago to 12 hours ago 13 | // - `hubot graf db graphite-carbon-metrics:3 now-8d now-1d` - Get only the third panel of a particular dashboard with a window of 8 days ago to yesterday 14 | // - `hubot graf db graphite-carbon-metrics:3 tz=Europe/Amsterdam` - Get only the third panel of a particular dashboard and render in the time zone Europe/Amsterdam 15 | // 16 | // Configuration: 17 | // HUBOT_GRAFANA_HOST - Host for your Grafana 2.0 install, e.g. 'https://play.grafana.org' 18 | // HUBOT_GRAFANA_API_KEY - API key for a particular user (leave unset if unauthenticated) 19 | // HUBOT_GRAFANA_PER_ROOM - Optional; if set use robot brain to store host & API key per room 20 | // HUBOT_GRAFANA_QUERY_TIME_RANGE - Optional; Default time range for queries (defaults to 6h) 21 | // HUBOT_GRAFANA_DEFAULT_WIDTH - Optional; Default width for rendered images (defaults to 1000) 22 | // HUBOT_GRAFANA_DEFAULT_HEIGHT - Optional; Default height for rendered images (defaults to 500) 23 | // HUBOT_GRAFANA_DEFAULT_TIME_ZONE - Optional; Default time zone (default to "") 24 | // HUBOT_GRAFANA_S3_BUCKET - Optional; Name of the S3 bucket to copy the graph into 25 | // HUBOT_GRAFANA_S3_PREFIX - Optional; Bucket prefix (useful for shared buckets) 26 | // HUBOT_GRAFANA_S3_REGION - Optional; Bucket region (defaults to us-standard) 27 | // HUBOT_GRAFANA_USE_THREADS - Optional; When set to any value, graphs are sent in thread instead of as new message. 28 | // ROCKETCHAT_URL - Optional; URL to your Rocket.Chat instance (already configured with the adapter) 29 | // ROCKETCHAT_USER - Optional; Bot username (already configured with the adapter) 30 | // ROCKETCHAT_PASSWORD - Optional; Bot password (already configured with the adapter) 31 | // 32 | // Notes: 33 | // If you want to use the Slack adapter's "attachment" formatting: 34 | // hubot: v2.7.2+ 35 | // hubot-slack: 4.0+ 36 | // @hubot-friends/hubot-slack: 1.0+ 37 | // 38 | // Commands: 39 | // hubot graf set `[host|api_key]` - Setup Grafana host or API key 40 | // hubot graf db [:][