Last active
August 27, 2025 14:37
-
-
Save WesselAtWork/1b842be82b992fc3eb9f953d7303277c to your computer and use it in GitHub Desktop.
Grafana PDF GraphQL Reporter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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