├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ └── build_release.yml ├── Containerfile ├── README.md ├── grafana_pdf.js ├── grafana_pdf_exporter.sh └── sendgridSendEmail.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | .git* 2 | Dockerfile* 3 | Containerfile* 4 | .dockerignore 5 | .pre-commit* 6 | docker-compose.yml 7 | venv* 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | labels: 8 | - "dependencies" 9 | - "node" 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | labels: 15 | - "dependencies" 16 | -------------------------------------------------------------------------------- /.github/workflows/build_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*.*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | env: 14 | REGISTRY: ghcr.io 15 | IMAGE_NAME: ${{ github.repository }} 16 | 17 | jobs: 18 | container: 19 | name: Containerfile 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | os: 25 | - ubuntu-22.04 26 | include: 27 | - os: ubuntu-22.04 28 | platform: linux 29 | arch: amd64 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v3 36 | 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v3 39 | 40 | - name: Login to Container Registry 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ${{ env.REGISTRY }} 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Extract metadata 48 | uses: docker/metadata-action@v5 49 | id: metadata 50 | with: 51 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 52 | 53 | - name: Build image 54 | uses: docker/build-push-action@v6 55 | with: 56 | context: . 57 | file: Containerfile 58 | push: ${{ github.event_name != 'pull_request' }} 59 | platforms: ${{ matrix.platform }}/${{ matrix.arch }} 60 | tags: ${{ steps.metadata.outputs.tags }} 61 | labels: ${{ steps.metadata.outputs.labels }} 62 | 63 | release: 64 | runs-on: ubuntu-22.04 65 | needs: 66 | - container 67 | if: startsWith(github.ref, 'refs/tags/') 68 | steps: 69 | - name: Download artifacts 70 | uses: actions/download-artifact@v4 71 | 72 | - name: Package 73 | run: | 74 | for folder in ./*; do 75 | if [ -d "$folder" ]; then 76 | echo "Processing folder: $folder" 77 | cd $folder 78 | tar -czf ../$folder.tar.gz -T <(\ls -1) 79 | cd .. 80 | sha256sum $folder.tar.gz > $folder.tar.gz.sha256 81 | fi 82 | done 83 | 84 | - name: Release 85 | uses: svenstaro/upload-release-action@v2 86 | with: 87 | repo_token: ${{ secrets.GITHUB_TOKEN }} 88 | file: '*.tar.gz*' 89 | tag: ${{ github.ref }} 90 | file_glob: true 91 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM node:21.1.0-bookworm-slim 2 | 3 | # We don't need the standalone Chromium 4 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | 7 | # Install Google Chrome Stable and fonts 8 | # Note: this installs the necessary libs to make the browser work with Puppeteer. 9 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked apt-get update \ 10 | && apt-get install -y gnupg wget awscli curl \ 11 | && wget --quiet --output-document=- https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /etc/apt/trusted.gpg.d/google-archive.gpg \ 12 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 13 | && apt-get update && apt-get install google-chrome-stable -y --no-install-recommends \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | USER node 17 | WORKDIR /app 18 | 19 | RUN npm install puppeteer 20 | 21 | COPY --chown=node:node . . 22 | 23 | # just run the container doing nothing 24 | ENTRYPOINT ["sh", "-c", "sleep infinity"] 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grafana-pdf-exporter 2 | 3 | Export a Grafana dashboard as PDF 4 | 5 | ## How to run 6 | 7 | Exported dashboards can be sent using SendGrid, in order to do this, one has to set these environment variables: 8 | - FROM_NAME 9 | - FROM_EMAIL 10 | - SENDGRID_API_KEY 11 | 12 | ```sh 13 | sh sendgridSendEmail.sh -t 'to1@gmail.com;to2@gmail.com' -s 'FINAL SCRIPT' -o '\Email body goes here\<\/p\>' -a '/tmp/test.sh;/tmp/test2.sh' 14 | ``` 15 | 16 | ## Supported Grafana versions 17 | 18 | | Release | Grafana versions | 19 | | :-------- | :--------------: | 20 | | v1.7.0 | >= 8 | 21 | 22 | ## Contribution 23 | 24 | Thanks to @salv for the original nodejs implementation 25 | -------------------------------------------------------------------------------- /grafana_pdf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const puppeteer = require('puppeteer'); 4 | 5 | // URL to load should be passed as first parameter 6 | //const url = process.argv[2]; 7 | const url = process.env.URL; 8 | // Username and password (with colon separator) should be second parameter 9 | const auth_string = process.env.CREDS; 10 | // Output file name should be third parameter 11 | const outfile = process.env.OUTPUT; 12 | 13 | // TODO: Output an error message if number of arguments is not right or 14 | // arguments are invalid 15 | 16 | // Set the browser width in pixels. The paper size will be calculated on the 17 | // basus of 96dpi, so 1200 corresponds to 12.5". 18 | const width_px = 1200; 19 | // Note that to get an actual paper size, e.g. Letter, you will want to *not* 20 | // simply set the pixel size here, since that would lead to a "mobile-sized" 21 | // screen (816px), and mess up the rendering. Instead, set e.g. double the size 22 | // here (1632px), and call page.pdf() with format: 'Letter' and scale = 0.5. 23 | 24 | // Generate authorization header for basic auth 25 | const auth_header = 'Basic ' + new Buffer.from(auth_string).toString('base64'); 26 | 27 | (async() => { 28 | const browser = await puppeteer.launch({ 29 | args: [ 30 | '--no-sandbox', 31 | '--disable-setuid-sandbox', 32 | '--disable-dev-shm-usage', 33 | '--disable-gpu', 34 | ], 35 | executablePath: '/usr/bin/google-chrome', 36 | }); 37 | const page = await browser.newPage(); 38 | 39 | // Set basic auth headers 40 | await page.setExtraHTTPHeaders({'Authorization': auth_header}); 41 | 42 | // Increase timeout from the default of 30 seconds to 120 seconds, to allow 43 | // for slow-loading panels 44 | await page.setDefaultNavigationTimeout(240000); 45 | 46 | // Increasing the deviceScaleFactor gets a higher-resolution image. The width 47 | // should be set to the same value as in page.pdf() below. The height is not 48 | // important 49 | await page.setViewport({ 50 | width: width_px, 51 | height: 800, 52 | deviceScaleFactor: 2, 53 | isMobile: false 54 | }) 55 | 56 | // Wait until all network connections are closed (and none are opened withing 57 | // 0.5s). In some cases it may be appropriate to change this to {waitUntil: 58 | // 'networkidle2'}, which stops when there are only 2 or fewer connections 59 | // remaining. 60 | await page.goto(url, {waitUntil: 'networkidle0'}); 61 | 62 | // Hide all panel description (top-left "i") pop-up handles and, all panel 63 | // resize handles. Annoyingly, it seems you can't concatenate the two object 64 | // collections into one 65 | await page.evaluate(() => { 66 | let infoCorners = document.getElementsByClassName('panel-info-corner'); 67 | for (el of infoCorners) { el.hidden = true; }; 68 | let resizeHandles = document.getElementsByClassName('react-resizable-handle'); 69 | for (el of resizeHandles) { el.hidden = true; }; 70 | }); 71 | 72 | // Get the height of the main canvas, and add a margin 73 | var height_px = await page.evaluate(() => { 74 | return document.getElementsByClassName('react-grid-layout')[0]?.getBoundingClientRect?.().bottom || 540 ; 75 | }) + 20; 76 | 77 | // Francois: wait for page to be navigable (2min should be more than enough 78 | // for longrange queries) 79 | await page.waitForTimeout(120000); 80 | 81 | await page.pdf({ 82 | path: outfile, 83 | width: width_px + 'px', 84 | height: height_px + 'px', 85 | // format: 'Letter', <-- see note above for generating "paper-sized" outputs 86 | scale: 1, 87 | displayHeaderFooter: false, 88 | margin: { 89 | top: 0, 90 | right: 0, 91 | bottom: 0, 92 | left: 0, 93 | }, 94 | }); 95 | 96 | await browser.close(); 97 | })(); 98 | -------------------------------------------------------------------------------- /grafana_pdf_exporter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Wrapper for grafana_pdf.js 4 | # Will copy the pdf file to an AWS bucket 5 | 6 | # set -x 7 | 8 | URL="$(echo ${1})" 9 | CREDS=${GRAFANA_USER}:${GRAFANA_PASSWORD} 10 | OUTPUT="$(echo ${2})" 11 | 12 | # Node.js require us to pass environment variables this way 13 | URL=$URL CREDS=$CREDS OUTPUT=$OUTPUT node --unhandled-rejections=strict grafana_pdf.js 14 | 15 | if [ ! -f $2 ]; then 16 | echo "Grafana dashboard $1 not exported to $2, exiting" 17 | exit 1 18 | fi 19 | 20 | ### is AWS_REGION mandatory? 21 | #if [ ! -z "$AWS_BUCKET_NAME" && ! -z $AWS_ACCESS_KEY_ID && ! -z $AWS_SECRET_ACCESS_KEY ]; then 22 | # echo "Copying $2 to s3://$AWS_BUCKET_NAME/" 23 | # aws s3 cp $2 s3://$AWS_BUCKET_NAME/ 24 | # exit 0 25 | #fi 26 | -------------------------------------------------------------------------------- /sendgridSendEmail.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Author: Amith Chandrappa 3 | 4 | 5 | # Usage 6 | # Options: 7 | # -t: To Emails, Separated by ";" 8 | # -c: CC Emails, Separated by ";" 9 | # -b: BCC Emails, Separated by ";" 10 | # -s: Subject 11 | # -o: Email body 12 | # -a: Attachment Files, Separated by ";" 13 | # 14 | # Example: 15 | # sh sendEmail.sh 16 | # -t 'to1@gmail.com;to2@gmail.com' 17 | # -c 'cc1@gmail.com;cc2@gmail.com' 18 | # -b 'bcc1@gmail.com;bcc2@gmail.com' 19 | # -s 'FINAL SCRIPT' 20 | # -o '

Email body goes here

' 21 | # -a '/tmp/test.sh;/tmp/test2.sh' 22 | 23 | # Your Sendgrid API Key 24 | #SENDGRID_API_KEY="" 25 | 26 | #FROM_NAME="" 27 | #FROM_EMAIL="" 28 | 29 | # Get the arguments 30 | while getopts t:c:b:s:o:a: flag 31 | do 32 | case "${flag}" in 33 | t) to=${OPTARG};; 34 | c) cc=${OPTARG};; 35 | b) bcc=${OPTARG};; 36 | s) subject=${OPTARG};; 37 | o) body=${OPTARG};; 38 | a) attachments=${OPTARG};; 39 | esac 40 | done 41 | 42 | 43 | # Start building the JSON 44 | sendGridJson="{\"personalizations\": [{"; 45 | 46 | # Convert the String to Array, with the delimiter as ";" 47 | IFS='; ' read -r -a to_array <<< "$to" 48 | IFS='; ' read -r -a cc_array <<< "$cc" 49 | IFS='; ' read -r -a bcc_array <<< "$bcc" 50 | IFS='; ' read -r -a attachments_array <<< "$attachments" 51 | 52 | 53 | if [ ${#to_array[@]} != 0 ] 54 | then 55 | sendGridJson="${sendGridJson} \"to\": [" 56 | 57 | for email in "${to_array[@]}" 58 | do 59 | sendGridJson="${sendGridJson} {\"email\": \"$email\"}," 60 | done 61 | 62 | sendGridJson=`echo ${sendGridJson} | sed 's/.$//'` 63 | sendGridJson="${sendGridJson} ]," 64 | 65 | if [ ${#cc_array[@]} == 0 ] && [ ${#bcc_array[@]} == 0 ] 66 | then 67 | sendGridJson=`echo ${sendGridJson} | sed 's/.$//'` 68 | fi 69 | fi 70 | 71 | if [ ${#cc_array[@]} != 0 ] 72 | then 73 | sendGridJson="${sendGridJson} \"cc\": [" 74 | 75 | for email in "${cc_array[@]}" 76 | do 77 | sendGridJson="${sendGridJson} {\"email\": \"$email\"}," 78 | done 79 | 80 | sendGridJson=`echo ${sendGridJson} | sed 's/.$//'` 81 | sendGridJson="${sendGridJson} ]," 82 | 83 | if [ ${#bcc_array[@]} == 0 ] 84 | then 85 | sendGridJson=`echo ${sendGridJson} | sed 's/.$//'` 86 | fi 87 | fi 88 | 89 | if [ ${#bcc_array[@]} != 0 ] 90 | then 91 | sendGridJson="${sendGridJson} \"bcc\": [" 92 | 93 | for email in "${bcc_array[@]}" 94 | do 95 | sendGridJson="${sendGridJson} {\"email\": \"$email\"}," 96 | done 97 | 98 | sendGridJson=`echo ${sendGridJson} | sed 's/.$//'` 99 | sendGridJson="${sendGridJson} ]" 100 | fi 101 | 102 | sendGridJson="${sendGridJson} }],\"from\": {\"email\": \"${FROM_EMAIL}\",\"name\": \"${FROM_NAME}\"},\"subject\":\"${subject}\",\"content\": [{\"type\": \"text/html\",\"value\": \"${body}\"}]," 103 | 104 | if [ ${#attachments_array[@]} != 0 ] 105 | then 106 | sendGridJson="${sendGridJson} \"attachments\": [" 107 | 108 | for attachment in "${attachments_array[@]}" 109 | 110 | # Converting the File Content to Base64 111 | # For OSX use base64 112 | # For linux use base64 -w 0 < 113 | 114 | do 115 | base64_content=$(base64 -w 0 < ${attachment}) 116 | fileName="$(basename $attachment)" 117 | sendGridJson="${sendGridJson} {\"content\": \"${base64_content}\",\"type\": \"text/plain\",\"filename\": \"${fileName}\"}," 118 | done 119 | 120 | sendGridJson=`echo ${sendGridJson} | sed 's/.$//'` 121 | sendGridJson="${sendGridJson} ]" 122 | else 123 | sendGridJson=`echo ${sendGridJson} | sed 's/.$//'` 124 | fi 125 | 126 | sendGridJson="${sendGridJson} }" 127 | 128 | #Generate a Random File to hole the POST data 129 | tfile=$(mktemp /tmp/sendgrid.XXXXXXXXX) 130 | echo $sendGridJson > $tfile 131 | 132 | # Send the http request to SendGrid 133 | curl --request POST \ 134 | --url https://api.sendgrid.com/v3/mail/send \ 135 | --header 'Authorization: Bearer '$SENDGRID_API_KEY \ 136 | --header 'Content-Type: application/json' \ 137 | --data @$tfile 138 | --------------------------------------------------------------------------------