{"id":1080,"date":"2025-12-29T18:07:40","date_gmt":"2025-12-29T18:07:40","guid":{"rendered":"https:\/\/james-batchelor.com\/?p=1080"},"modified":"2025-12-29T18:07:40","modified_gmt":"2025-12-29T18:07:40","slug":"grafana-static-displays","status":"publish","type":"post","link":"https:\/\/james-batchelor.com\/index.php\/2025\/12\/29\/grafana-static-displays\/","title":{"rendered":"Grafana Static Displays"},"content":{"rendered":"\n<p>In the last post I <a href=\"https:\/\/james-batchelor.com\/index.php\/2025\/11\/09\/migrate-zabbix-grafana-to-new-server\/\" data-type=\"post\" data-id=\"1067\">migrated a Grafana database to a new server<\/a> and took the opportunity to upgrade the Grafana version in the process.<\/p>\n\n\n\n<p>This created a new issue, since Grafana\u2019s supported browser list moves with the times, my two \u201cNOC\u201d screens consisting each of a Raspberry Pi 3A running Raspbian Buster and Hyperpixel 4.0 screens no longer worked. Instead, the outdated Chromium browsers now displayed the \u201cIf you&#8217;re seeing this Grafana has failed to load its application files\u201d page when visiting the Grafana web interface.<\/p>\n\n\n\n<p>Bringing the OS on the Pi\u2019s up to date is the logical resolution, however, there are a couple of issues with this:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>One of the Hyperpixel 4.0 screens is an early revision model, meaning the official and legacy drivers do not work.<\/li>\n\n\n\n<li>Even with working screen drivers, compatible browsers will not load on the 512MB RAM available to the Pi 3A.<\/li>\n<\/ul>\n\n\n\n<p>Not wanting to obsolete a pair of Pi 3A\u2019s with a now unique form factor, instead a workaround was found to continue displaying Grafana dashboards on this older hardware. The solution even gave a slight benefit over the original URL\u2026<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-scaled.jpg\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"515\" src=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-1024x515.jpg\" alt=\"\" class=\"wp-image-1081\" srcset=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-1024x515.jpg 1024w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-300x151.jpg 300w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-768x386.jpg 768w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-1536x773.jpg 1536w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-2048x1031.jpg 2048w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/20251219_1334091-1200x604.jpg 1200w\" sizes=\"auto, (max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px\" \/><\/a><\/figure>\n\n\n\n<!--more-->\n\n\n\n<p>To achieve this, Grafana has released <a href=\"https:\/\/github.com\/grafana\/grafana-image-renderer\" target=\"_blank\" rel=\"noreferrer noopener\">grafana-image-renderer<\/a>, essentially a binary with a self-contained and headless Chromium instance that will load a dashboard and capture an image of the output.<\/p>\n\n\n\n<p>Until recently, this was available as a Grafana plugin, however this plugin has since been depreciated in favour of it being a standalone application or docker container. A lot of online documentation still refers to it in its plugin form and so is now outdated.<\/p>\n\n\n\n<p>The current iteration of grafana-image-renderer recommends using it within a docker container due to the potential resource usage it can generate. However, for this guide, only the standalone binary will be used as its only generating two images, and I find resource usage is at a minimum.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Installation<\/h2>\n\n\n\n<p>Firstly, grab the binary and store it in a easy but sensible location:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cd \/opt\nwget <a href=\"https:\/\/github.com\/grafana\/grafana-image-renderer\/releases\/download\/v5.0.6\/grafana-image-renderer-linux-amd64\">https:\/\/github.com\/grafana\/grafana-image-renderer\/releases\/download\/v5.0.6\/grafana-image-renderer-linux-amd64<\/a><\/code><\/pre>\n\n\n\n<p>Then make the file executable:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>chmod +x grafana-image-renderer-linux-amd64<\/code><\/pre>\n\n\n\n<p>To have it run as a service, create a service file for it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>nano\u00a0\/etc\/systemd\/system\/grafana-image-renderer.service<\/code><\/pre>\n\n\n\n<p>And populate the file with the following:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&#91;Unit]\nDescription=Grafana Image Renderer service\nAfter=network.target\n\n&#91;Service]\nExecStart=\/opt\/grafana-image-renderer-linux-amd64 server --\nWorkingDirectory=\/opt\/\nRestart=on-failure\nUser=root\nEnvironment=GRAFANA_RENDERER_PLUGIN_STARTUP_TIMEOUT=30s \n&#91;Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p>Reload the service daemon, start the new service, and check it is running:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>systemctl daemon-reload\nsystemctl start grafana-image-renderer.service\nsystemctl status grafana-image-renderer.service<\/code><\/pre>\n\n\n\n<p>You can check If the service is running by visiting the web page of the server on port 8081:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-165323.png\"><img loading=\"lazy\" decoding=\"async\" width=\"993\" height=\"370\" src=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-165323.png\" alt=\"\" class=\"wp-image-1082\" srcset=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-165323.png 993w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-165323-300x112.png 300w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-165323-768x286.png 768w\" sizes=\"auto, (max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px\" \/><\/a><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Authentication<\/h2>\n\n\n\n<p>A default Grafana server install requires login before being able to see any dashboards, this extends to the image renderer. As we can\u2019t login to the web interface interactively within the service, a service account needs to be created.<\/p>\n\n\n\n<p>In the main Grafana web interface, login and navigate to Home &gt; Administration &gt; Users and access &gt; Service accounts, then click \u201cAdd service account\u201d.<\/p>\n\n\n\n<p>Give it a display name and set the role to viewer:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101.png\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"408\" src=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101-1024x408.png\" alt=\"\" class=\"wp-image-1083\" srcset=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101-1024x408.png 1024w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101-300x120.png 300w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101-768x306.png 768w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101-1536x613.png 1536w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101-1200x479.png 1200w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-171101.png 1612w\" sizes=\"auto, (max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px\" \/><\/a><\/figure>\n\n\n\n<p>Now in the newly created service account, click \u201cAdd service token\u201d, then on the popup dialog, click \u201cGenerate Token\u201d.<\/p>\n\n\n\n<p>A token with a \u201cglsa_\u201d prefix is displayed, make a copy of this as it will not be available to view after closing this dialog box:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-170548.png\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"500\" src=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-170548-1024x500.png\" alt=\"\" class=\"wp-image-1084\" srcset=\"https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-170548-1024x500.png 1024w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-170548-300x146.png 300w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-170548-768x375.png 768w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-170548-1200x586.png 1200w, https:\/\/james-batchelor.com\/wp-content\/uploads\/2025\/12\/Screenshot-2025-12-28-170548.png 1430w\" sizes=\"auto, (max-width: 709px) 85vw, (max-width: 909px) 67vw, (max-width: 1362px) 62vw, 840px\" \/><\/a><\/figure>\n\n\n\n<p>This will be used as authentication when generating our dashboard images\u2026<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Generating Images<\/h2>\n\n\n\n<p>With the renderer installed, and authentication available, images can now be generated.<\/p>\n\n\n\n<p>Images are generated by requesting a URL from the service with the dashboard within the variables, the syntax is:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>http:&#47;&#47;{grafana-image-renderer}:8081\/render?encoding=png&amp;url=http:\/\/{grafana-dashboard}?{options}<\/code><\/pre>\n\n\n\n<p>Where:<br><em>Grafana-image-renderer<\/em> &#8211; The IP of the machine the renderer is running on.<br><em>Grafana-dashboard<\/em> &#8211; The URL of the dashboard you\u2019d like to capture.<br><em>Options<\/em> &#8211; Options to manipulate the output\/how the dashboard is displayed.<\/p>\n\n\n\n<p>However, the authentication header is needed to successfully access the dashboard. For this reason its easier to use a curl command to set the headers and grab the image<\/p>\n\n\n\n<p>For example, if the renderer is running on the same machine as the main Grafana, a dashboard can be generated by executing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -H \"X-Auth-Token: {glsa_token}\" \"http:\/\/localhost:8081\/render?encoding=png&amp;url=http:\/\/localhost:3000\/d\/{id}\/{dashboad}?kiosk&amp;width=800&amp;height=600\" -o image.png<\/code><\/pre>\n\n\n\n<p>The options used here is kiosk; to remove the menus from the output, and height and width to match the intended displays.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Automation:<\/h2>\n\n\n\n<p>One minute updates are fine for this use case, so the easiest way automate this is via cron. Using crontab -e, add the following line:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/usr\/bin\/curl -H \"X-Auth-Token: {glsa_token}\" \"http:\/\/localhost:8081\/render?encoding=png&amp;url=http:\/\/localhost:3000\/d\/{id}\/{dashboard}?kiosk&amp;width=800&amp;height=600\" -o \/var\/www\/html\/dash1.png<\/code><\/pre>\n\n\n\n<p>This line generates the dashboard every minute and stores it on the default web server directory, allowing it to be viewed on the network via an installed web server.<\/p>\n\n\n\n<p>But this will only create a static image, to allow the image to update on a display, a simple webpage can be created and stored on the webserver for easier access and reference.<\/p>\n\n\n\n<p>Create a new webpage:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>nano \/var\/www\/html\/dashboard.html<\/code><\/pre>\n\n\n\n<p>And paste the following:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;!DOCTYPE html>\n&lt;html lang=\"en\">\n&lt;head>\n\u00a0 &lt;meta charset=\"UTF-8\">\n\u00a0 &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\u00a0 &lt;title>Grafana Dashboard&lt;\/title>\n\u00a0 &lt;style>\n\u00a0\u00a0\u00a0 body {\n\u00a0\u00a0\u00a0\u00a0\u00a0 margin: 0;\n\u00a0\u00a0\u00a0\u00a0\u00a0 background-color: black;\n\u00a0\u00a0\u00a0\u00a0\u00a0 display: flex;\n\u00a0\u00a0\u00a0\u00a0\u00a0 justify-content: center;\n\u00a0\u00a0\u00a0\u00a0\u00a0 align-items: center;\n\u00a0\u00a0\u00a0\u00a0\u00a0 height: 100vh;\n\u00a0\u00a0\u00a0 }\n\u00a0\u00a0\u00a0 img {\n\u00a0\u00a0\u00a0\u00a0\u00a0 max-width: 100%;\n\u00a0\u00a0\u00a0\u00a0\u00a0 max-height: 100%;\n\u00a0\u00a0\u00a0\u00a0\u00a0 object-fit: contain;\n\u00a0\u00a0\u00a0 }\n\u00a0 &lt;\/style>\n&lt;\/head>\n&lt;body>\n\u00a0 &lt;img id=\"liveImage\" src=\"dash1.png\" alt=\"Live Image\">\n\u00a0 &lt;script>\n\u00a0\u00a0\u00a0 const img = document.getElementById(\"liveImage\");\n\u00a0\u00a0\u00a0 setInterval(() => {\n\u00a0\u00a0\u00a0\u00a0\u00a0 const timestamp = new Date().getTime(); \/\/ avoid browser cache\n\u00a0\u00a0\u00a0\u00a0\u00a0 img.src = \"dash1.png?t=\" + timestamp;\n\u00a0\u00a0\u00a0 }, 60000); \/\/ 60 seconds\n\u00a0 &lt;\/script>\n&lt;\/body>\n&lt;\/html><\/code><\/pre>\n\n\n\n<p>When visited, this webpage will update the same image (dash1.png) every 60 seconds to ensure the browser updates with the latest available image instead of caching.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Displays<\/h2>\n\n\n\n<p>Viewing this on the display is as simple as visiting the above webpage. As the html is served from the Grafana server and where the auto refresh is taken care of, as long as the display is able to load the page, all is taken care of at the Grafana server.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Summary<\/h2>\n\n\n\n<p>Since all that is required from a display is being able to load and render a static image, this allows the oldest and slowest machines to be able to display a Grafana dashboard, negating the need to keep the displays up to date with Grafana\u2019s evolving browser support.<\/p>\n\n\n\n<p>I wish I learnt of this sooner, as it gives more standardised control of the layout of a dashboard. The HyperPixel displays are quite high resolution for their size, and loading a dashboard natively often lead to frustrations with the layout of panels not matching that displyed on a laptop\/desktop screen. Presumably due to scaling. Using the image render allows you to test and fix the layout before deploying to a screen, and with a higher panel density too.<\/p>\n\n\n\n<p>Another benefit is that most processing is taken away from the Pi 3A\u2019s, as it is now only responsible for displaying a simple webpage with a single image, rather than loading the full Grafana UI. The Pis have been running on this new method for over a month without any lockups, which was previously seen on a weekly basis.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>In the last post I migrated a Grafana database to a new server and took the opportunity to upgrade the Grafana version in the process. This created a new issue, since Grafana\u2019s supported browser list moves with the times, my two \u201cNOC\u201d screens consisting each of a Raspberry Pi 3A running Raspbian Buster and Hyperpixel &hellip; <a href=\"https:\/\/james-batchelor.com\/index.php\/2025\/12\/29\/grafana-static-displays\/\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;Grafana Static Displays&#8221;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4,5],"tags":[419,421,422,139],"class_list":["post-1080","post","type-post","status-publish","format-standard","hentry","category-raspberry-pi","category-servers","tag-grafana","tag-grafana-image-renderer","tag-plugin","tag-standalone"],"_links":{"self":[{"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/posts\/1080","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/comments?post=1080"}],"version-history":[{"count":2,"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/posts\/1080\/revisions"}],"predecessor-version":[{"id":1086,"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/posts\/1080\/revisions\/1086"}],"wp:attachment":[{"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/media?parent=1080"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/categories?post=1080"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/james-batchelor.com\/index.php\/wp-json\/wp\/v2\/tags?post=1080"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}