Skip to content

Instantly share code, notes, and snippets.

@WesselAtWork
Last active August 27, 2025 14:37
Show Gist options
  • Select an option

  • Save WesselAtWork/1b842be82b992fc3eb9f953d7303277c to your computer and use it in GitHub Desktop.

Select an option

Save WesselAtWork/1b842be82b992fc3eb9f953d7303277c to your computer and use it in GitHub Desktop.
Grafana PDF GraphQL Reporter
FROM docker.io/node:22-alpine
RUN apk --no-cache add chromium
# skips puppeteer installing chrome and points to correct binary
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
RUN addgroup pptruser && adduser -D -g pptruser -G pptruser pptruser
USER pptruser
WORKDIR /home/pptruser
# Install into /home/pptruser/node_modules.
RUN npm install puppeteer puppeteer-core @puppeteer/browsers @microsoft/microsoft-graph-client @azure/identity
COPY ./export_grafana_pdf.js /home/pptruser/export_grafana_pdf.js
CMD ["node", "/home/pptruser/export_grafana_pdf.js"]
// REFERANCES:
// https://gist.github.com/svet-b/1ad0656cd3ce0e1a633e16eb20f66425
// https://github.com/wiremind/grafana-pdf-exporter
// view dashboards like it's 1971!
'use strict';
console.log("INFO: Loading Env");
const puppeteer = require('puppeteer');
// # Variables
// ## Grafana
const grafana_url = process.env.GRAFANA_URL; // "{protocol}://{host}:{port}", "http://some-host:3000", "https://domain"
const dashboard_id = process.env.DASHBOARD_ID; // "{UID}/{NAME}", "rYdddlPWk/node-exporter-full"
// const bearer_auth_string = process.env.GRAFANA_TOKEN; // couldn't get service accounts to work :(
const basic_auth_string = process.env.GRAFANA_CREDS; // "{user}:{password}", "mygrafuser:mygrafpa33word"
const url = grafana_url + "/d/" + dashboard_id + "?kiosk"; // should result in "{protocol}://{domain}/d/{UID}/{NAME}?kiosk"
// Generate authorization header for Grafana auth
const auth_header = 'Basic ' + Buffer.from(basic_auth_string).toString('base64');
// ## Microsoft
const client_id = process.env.CLIENT_ID;
const client_secret = process.env.CLIENT_SECRET;
const tenant = process.env.TENANT;
// Depending on what you are doing, you might not need the USER PRINCIPAL
// https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=javascript#http-request
const graph_user = process.env.USER_PRINCIPAL_NAME ? ('users/' + process.env.USER_PRINCIPAL_NAME) : 'me';
// ## Emails
const emails = process.env.EMAILS.split(','); // "{email1},{email2}"
//guess what! ',' is a VALID RFC CHARACTER IN EMAILS! Hope you don't have any of those <3
const subject = process.env.SUBJECT || "Grafana PDF Dashboard!";
const body = { contentType: "Text", content: (process.env.BODY || "Please find attached Dashboard") };
const file_name = process.env.FILE_NAME || "dashboard"
// ## Other
// Set the browser width in pixels. The paper size will be calculated on 96dpi (i think ???)
// 1200px@96dpi corresponds to 12.5". ~letter
// 796px@96dpi corresponds to 210mm. ~a4
// 1123px@96dpi corresponds to 297mm. ~a3
// 1587px@96dpi corresponds to 420mm. ~a2
const width_px = parseInt(process.env.WIDTH_PX) || 1587;
// I found this value is to be pretty good for most dashboards
// ---
// # Microsoft Graph Client
// there is probably a better way to do this, ¯\_(ツ)_/¯.
const az = require("@azure/identity");
const aztc = require("@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials");
const msgc = require("@microsoft/microsoft-graph-client")
// adjust to your own needs
// https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers?tabs=typescript#client-credentials-provider
const credential = new az.ClientSecretCredential(
tenant,
client_id,
client_secret,
);
const authProvider = new aztc.TokenCredentialAuthenticationProvider(credential, {
scopes: ['https://graph.microsoft.com/.default'],
});
const graphClient = msgc.Client.initWithMiddleware({ authProvider: authProvider });
// ---
console.log("INFO: Starting...");
(async() => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setExtraHTTPHeaders({'Authorization': auth_header});
// Increase timeout to 120 seconds, to allow for slow-loading panels and long running queries
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 important, it effects the visable area
// for the grafana application to render on.
await page.setViewport({
width: width_px,
height: 99999,
deviceScaleFactor: 1,
isMobile: false
})
console.log("INFO: Going to", dashboard_id);
// 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.toString(), {waitUntil: 'networkidle0'});
// Checking the url path to make sure we are not redirected to the login page
const dashurlstr = await page.url();
const dashurl = new URL(dashurlstr);
const dashpath = dashurl.pathname;
if (dashpath == '/login') {
console.error("ERROR: Redirected to the Login page!");
console.error("HINT: Does your user exist?");
process.exit(1);
}
console.log("INFO: Openening Sections");
// This might look redundant but believe me it is required. T_T
await Promise.all([
page.$$eval('.dashboard-row--collapsed > button', bts => bts.forEach(b => b.click())),
page.waitForNetworkIdle({waitUntil: 'networkidle0'})
])
console.log("INFO: Waiting for the sections to load");
// Some weirdness, the above completes instantly, but without it, the bottom does not work as intended
await page.waitForNetworkIdle({waitUntil: 'networkidle0'});
// Get the height of the main canvas, and add a bottom margin
// This element contains the actual content, without it, we'll be using the ENTIRE viewport
let height_px = await page.$$eval('.react-grid-layout', divs => divs.reduce((acc, ele) => Math.min(ele.getBoundingClientRect().bottom, acc), Infinity)) + 20;
if (height_px > 99900) {
console.warn("WARN: PDF height is extreme! Please check the `react-grid-layout` element's height in a chromium based browser and then fix the `height_px` stanza");
}
if (!isFinite(height_px)) {
console.warn("WARN: Height was not Finite, falling back to safe page height");
height_px = Math.ceil(width_px * Math.SQRT2)
}
console.log("INFO: Making PDF", width_px.toString() + 'x' + height_px.toString());
// 'print' media type results in crappy output.
page.emulateMediaType('screen');
const pdfBuffArr = await page.pdf({
width: width_px + 'px',
height: height_px + 'px',
printBackground: true,
scale: 1,
displayHeaderFooter: false,
margin: {
top: 0,
right: 0,
bottom: 0,
left: 0,
},
});
browser.close(); //no need to wait for the browser to close
console.log("INFO: Creating Mail");
const pdfcontents = { "@odata.type": "#microsoft.graph.fileAttachment", name: (file_name + ".pdf"), contentType: "application/pdf", contentBytes: Buffer.from(pdfBuffArr).toString('base64') }
const toRecipients = emails.map((em) => ({emailAddress:{address: em}}))
const email = { message: { subject: subject, body: body, toRecipients: toRecipients, attachments: [pdfcontents] } }
console.log("INFO: Sending Mail");
await graphClient.api(graph_user + '/sendMail').post(email);
console.log("INFO: Mail Success!");
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment