Skip to content

Instantly share code, notes, and snippets.

@svet-b
Last active October 22, 2024 21:15
Show Gist options
  • Save svet-b/1ad0656cd3ce0e1a633e16eb20f66425 to your computer and use it in GitHub Desktop.
Save svet-b/1ad0656cd3ce0e1a633e16eb20f66425 to your computer and use it in GitHub Desktop.
PDF export of Grafana dashboard using puppeteer
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Automated PDF export of Grafana dashboard using Puppeteer

Prerequisites

General:

  • Grafana server with dashboards that are to be exported, and datasources in "Server" (proxy) mode.
  • User account on Grafana server that has Viewer access to the required dashboards
  • This has been tested on Ubuntu 16.04 and a Mac

Packages:

  • NodeJS, and the puppeteer package (npm install puppeteer), which is used to run headless Chrome
  • In Linux, Puppeteer has the following library/tool dependencies (primarily related to libx11 - see this post). I found that I didn't need extra packages on a Mac.
sudo apt-get install gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget

Scripts:

  • The grafana_pdf.js file attached here, which carries out the PDF conversion using Puppeteer

Process

Environment: Set the Grafana server URL, username, and password, and the output filename as environment variables.

export GF_DASH_URL="http://localhost:3000/d/x3g4Wx5ik/new-dashboard?kiosk"
export GF_USER=pdf_export
export GF_PASSWORD=StrongPassword1
export OUTPUT_PDF=output.pdf

Now export to PDF by calling the NodeJS script with the corresponding arguments:

node grafana_pdf.js $GF_DASH_URL $GF_USER:$GF_PASSWORD $OUTPUT_PDF

Notes and caveats

  • The focus here is on single-page output. Getting "tall" dashboards to paginate nicely is an altogether separate endeavor.
  • In its present form, the script adjusts the PDF and aspect ratio to fit the dashboard, with no regard for fitting on an actual page. It's also possible to get the output to be Letter or A4 sized - see comments in the code on how to achieve that; if the intent is to print, you'll probably also want to add a margin (TODO: Add a switch in the code)
  • When you have a single pdf_export user that is a member of multiple organizations, if you try to exporting dashboards belonging to different organizations one after the other, you will occasionally get a "login" screen instead of a dashboard during the org switch. When that happens, I found that simply retrying does the trick.

Example output

Attached below are two example output PDFs (bigdashboard_output.pdf and output_energy.pdf). The former is based on https://play.grafana.org/d/000000003/big-dashboard, and the latter is from our own energy monitoring project (www.ammp.io).

'use strict';
const puppeteer = require('puppeteer');
// URL to load should be passed as first parameter
const url = process.argv[2];
// Username and password (with colon separator) should be second parameter
const auth_string = process.argv[3];
// Output file name should be third parameter
const outfile = process.argv[4];
// TODO: Output an error message if number of arguments is not right or arguments are invalid
// Set the browser width in pixels. The paper size will be calculated on the basus of 96dpi,
// so 1200 corresponds to 12.5".
const width_px = 1200;
// Note that to get an actual paper size, e.g. Letter, you will want to *not* simply set the pixel
// size here, since that would lead to a "mobile-sized" screen (816px), and mess up the rendering.
// Instead, set e.g. double the size here (1632px), and call page.pdf() with format: 'Letter' and
// scale = 0.5.
// Generate authorization header for basic auth
const auth_header = 'Basic ' + new Buffer.from(auth_string).toString('base64');
(async() => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Set basic auth headers
await page.setExtraHTTPHeaders({'Authorization': auth_header});
// Increase timeout from the default of 30 seconds to 120 seconds, to allow for slow-loading panels
await page.setDefaultNavigationTimeout(120000);
// Increasing the deviceScaleFactor gets a higher-resolution image. The width should be set to
// the same value as in page.pdf() below. The height is not important
await page.setViewport({
width: width_px,
height: 800,
deviceScaleFactor: 2,
isMobile: false
})
// Wait until all network connections are closed (and none are opened withing 0.5s).
// In some cases it may be appropriate to change this to {waitUntil: 'networkidle2'},
// which stops when there are only 2 or fewer connections remaining.
await page.goto(url, {waitUntil: 'networkidle0'});
// Hide all panel description (top-left "i") pop-up handles and, all panel resize handles
// Annoyingly, it seems you can't concatenate the two object collections into one
await page.evaluate(() => {
let infoCorners = document.getElementsByClassName('panel-info-corner');
for (el of infoCorners) { el.hidden = true; };
let resizeHandles = document.getElementsByClassName('react-resizable-handle');
for (el of resizeHandles) { el.hidden = true; };
});
// Get the height of the main canvas, and add a margin
var height_px = await page.evaluate(() => {
return document.getElementsByClassName('react-grid-layout')[0].getBoundingClientRect().bottom;
}) + 20;
await page.pdf({
path: outfile,
width: width_px + 'px',
height: height_px + 'px',
// format: 'Letter', <-- see note above for generating "paper-sized" outputs
scale: 1,
displayHeaderFooter: false,
margin: {
top: 0,
right: 0,
bottom: 0,
left: 0,
},
});
await browser.close();
})();
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
@subodh2711
Copy link

subodh2711 commented Oct 12, 2022

Hi @coelh0 , i am getting blank pdf with error while opening "The dimensions of this page are out-of-range. Page content might be truncated.". can you please help.

@Bapths
Copy link

Bapths commented Feb 21, 2023

Hi everyone,

I am sorry to revive this conversation again but I managed to do something that may help. I'm not that much comfortable with nodeJS so I tried to do something similar using Python and playwright and I finally came with a satisfying solution for my needs.

import asyncio
from typing import Optional
from playwright.async_api import async_playwright


async def generate_pdf(
        target_url: str,
        auth_token: Optional[str],
        destination_file: str,
        page_width: int = 1920,
        margin: int = 80
) -> None:

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page(device_scale_factor=4)  # You can change the scale_factor to +/- images quality
        page.set_default_navigation_timeout(120000.0)
        await page.set_viewport_size({'width': page_width, 'height': 1080})
        await page.set_extra_http_headers(
            {'Authorization': f'Bearer {auth_token}'}
        )
        await page.goto(target_url)
        await page.wait_for_selector('.react-grid-layout')
        page_height = await page.evaluate(
            'document.getElementsByClassName(\'react-grid-layout\')[0].getBoundingClientRect().bottom;'
        )
        await page.set_viewport_size({'width': page_width, 'height': page_height})
        await page.wait_for_load_state('networkidle')
        await page.pdf(
            path=destination_file,
            scale=1,
            width=f'{page_width + margin * 2}px',
            height=f'{page_height + margin * 2}px',
            display_header_footer=False,
            print_background=True,
            margin={
                'top': f'{margin}px',
                'bottom': f'{margin}px',
                'left': f'{margin}px',
                'right': f'{margin}px',
            }
        )
        await browser.close()


async def main():

    target_url = 'YOUR_DASHBOARD_URL'  # Tip: add '&kiosk' at the end of your grafana URL to hide tool tabs
    grafana_auth_token: str = 'YOUR_API_KEY'
    destination_file: str = f'./grafana_report.pdf'

    await generate_pdf(
        target_url, grafana_auth_token, destination_file
    )

asyncio.run(main())

To sum it up, it opens the page, wait for the panels to load, get the page total height, set the viewport height to the page total height and print the result in a PDF file. The code might be still up-gradable but it work as it is.

Thanks for your really helpful tips and ideas! 😉

@kami619
Copy link

kami619 commented Aug 31, 2023

@Bapths I can confirm that this playwright python script works even now :)

just a note to install the below pip3 packages prior to the execution of the script

pip3 install playwright asyncio typing

and if you see a missing playwright._impl._api_types.Error: Executable doesn't exist at exception, just run the playwright install to download the needed browsers for the script to work.

@rafalkrupinski
Copy link

I'm on grafana 10.1 and it seems the panels are dynamically loaded and you need to scroll down to load them. For now I'm using height:2000 in page.setViewport, but there's also a way to make puppeteer scroll down.

@rafalkrupinski
Copy link

OK, I've tried page.scrollTo, scrollBy and

 const [scroller] = await page.$x('//div[contains(@style, "overflow: scroll")]');
if (scroller) {
  await scroller.evaluate(node => {
    node.scrollTop = node.scrollHeight;
  });
}```

Neither of which worked for me. I'll stick with `height` attribute.

@rafalkrupinski
Copy link

Ah, it's the kiosk mode disabling scrolling...

@zach-betz-hln
Copy link

Thank you for this example @svet-b.
It allowed us to create a somewhat printer friendly single page pdf.
For future readers, here's a Playwright snippet that worked for us, see code comments.

// Setup...

// Our tallest known dashboard is 3010px
// So set an arbitrarily high height so that all panels are loaded, which will be lowered later to fit the content
await page.setViewportSize({ width: 1920, height: 6000 });

// Enable kiosk mode in case the user is an admin
dashboardUrl.searchParams.set('kiosk', '1');
await page.goto(dashboardUrl.href);
await page.waitForLoadState('networkidle');

const dashboardHeight = await page.evaluate(() => {
  return document.querySelector('.react-grid-layout')?.getBoundingClientRect().bottom ?? 0;
});
// Fit the page to the content
await page.setViewportSize({ width: 1920, height: dashboardHeight });
await page.waitForLoadState('networkidle');

const margin = '10px';
const pdf = await page.pdf({
  scale: 0.4, // Tweak this to your needs
  format: 'Letter',
  printBackground: true,
  margin: {
    top: margin,
    bottom: margin,
    left: margin,
    right: margin
  }
});

// Do stuff with the pdf...

@arthur-mdn
Copy link

I've been working on docker and used part of your script to make it easier to configure : https://github.com/arthur-mdn/grafana-export-to-pdf

@dlc-unisim
Copy link

puppeteer by default paginates the pages unlike here.. but changing to Letter format is giving some hope where it acts as pivot . First row is changed to first page here. But subsequent rows are missing. Any suggestions here ?

Hi @Optimusbumble, did you got success with pagination? I'm trying to do the same, without success. Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment