diff --git a/config/cfgroot/graphs.pl b/config/cfgroot/graphs.pl
index e4c3613fb..beddff032 100644
--- a/config/cfgroot/graphs.pl
+++ b/config/cfgroot/graphs.pl
@@ -29,6 +29,12 @@ require '/var/ipfire/general-functions.pl';
 require "${General::swroot}/lang.pl";
 require "${General::swroot}/header.pl";
 
+# Graph image size in pixel
+our %image_size = ('width' => 910, 'height' => 300);
+
+# List of all available time ranges
+our @time_ranges = ("hour", "day", "week", "month", "year");
+
 my $ERROR;
 
 my @GRAPH_ARGS = (
@@ -48,8 +54,8 @@ my @GRAPH_ARGS = (
 	"-W www.ipfire.org",
 
 	# Default size
-	"-w 910",
-	"-h 300",
+	"-w $image_size{'width'}",
+	"-h $image_size{'height'}",
 
 	# Use alternative grid
 	"--alt-y-grid",
diff --git a/config/rootfiles/common/web-user-interface b/config/rootfiles/common/web-user-interface
index 540bf1e4b..23e9f3e5e 100644
--- a/config/rootfiles/common/web-user-interface
+++ b/config/rootfiles/common/web-user-interface
@@ -20,6 +20,7 @@ srv/web/ipfire/cgi-bin/extrahd.cgi
 srv/web/ipfire/cgi-bin/fireinfo.cgi
 srv/web/ipfire/cgi-bin/firewall.cgi
 srv/web/ipfire/cgi-bin/fwhosts.cgi
+srv/web/ipfire/cgi-bin/getrrdimage.cgi
 srv/web/ipfire/cgi-bin/gpl.cgi
 #srv/web/ipfire/cgi-bin/guardian.cgi
 srv/web/ipfire/cgi-bin/gui.cgi
@@ -300,6 +301,7 @@ srv/web/ipfire/html/images/view-refresh.png
 srv/web/ipfire/html/images/wakeup.gif
 srv/web/ipfire/html/images/window-new.png
 srv/web/ipfire/html/include
+srv/web/ipfire/html/include/rrdimage.js
 srv/web/ipfire/html/include/zoneconf.js
 srv/web/ipfire/html/index.cgi
 srv/web/ipfire/html/redirect-templates
diff --git a/html/cgi-bin/getrrdimage.cgi b/html/cgi-bin/getrrdimage.cgi
new file mode 100644
index 000000000..0caefe0ac
--- /dev/null
+++ b/html/cgi-bin/getrrdimage.cgi
@@ -0,0 +1,245 @@
+#!/usr/bin/perl
+###############################################################################
+#                                                                             #
+# IPFire.org - A linux based firewall                                         #
+# Copyright (C) 2005-2021  IPFire Team                                        #
+#                                                                             #
+# This program is free software: you can redistribute it and/or modify        #
+# it under the terms of the GNU General Public License as published by        #
+# the Free Software Foundation, either version 3 of the License, or           #
+# (at your option) any later version.                                         #
+#                                                                             #
+# This program is distributed in the hope that it will be useful,             #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of              #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the               #
+# GNU General Public License for more details.                                #
+#                                                                             #
+# You should have received a copy of the GNU General Public License           #
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.       #
+#                                                                             #
+###############################################################################
+
+use strict;
+use URI;
+use GD;
+use GD::Text::Wrap;
+use experimental 'smartmatch';
+
+# debugging
+#use warnings;
+#use CGI::Carp 'fatalsToBrowser';
+
+require '/var/ipfire/general-functions.pl';
+require "${General::swroot}/lang.pl";
+require "${General::swroot}/header.pl";
+require "${General::swroot}/graphs.pl";
+
+# List of graph origins that getrrdimage.cgi can process directly
+# (unknown origins are forwarded to ensure compatibility)
+my @supported_origins = ("entropy.cgi", "hardwaregraphs.cgi", "media.cgi",
+	"memory.cgi","netexternal.cgi", "netinternal.cgi", "netother.cgi",
+	"netovpnrw.cgi", "netovpnsrv.cgi", "qos.cgi", "system.cgi");
+
+### Process GET parameters ###
+# URL format: /?origin=[graph origin cgi]&graph=[graph name]&range=[time range]
+my $uri = URI->new($ENV{'REQUEST_URI'});
+my %query = $uri->query_form;
+
+my $origin = lc $query{'origin'}; # lower case
+my $graph = $query{'graph'};
+my $range = lc $query{'range'}; # lower case
+
+# Check parameters
+unless(($origin =~ /^\w+?\.cgi$/) && ($graph =~ /^[\w-]+?$/) && ($range ~~ @Graphs::time_ranges)) {
+	# Send HTTP headers
+	_start_png_output();
+	
+	_print_error("URL parameters missing or malformed.");	
+	exit;
+}
+
+# Unsupported graph origin: Redirect request to the CGI specified in the "origin" parameter
+# This enables backwards compatibility with addons that use Graphs::makegraphbox to ouput their own graphs
+unless($origin ~~ @supported_origins) {
+	# Rewrite to old URL format: /[graph origin cgi]?[graph name]?[time range]
+	my $location = "https://$ENV{'SERVER_NAME'}:$ENV{'SERVER_PORT'}/cgi-bin/${origin}?${graph}?${range}";
+	
+	# Send HTTP redirect
+	print "Status: 302 Found\n";
+	print "Location: $location\n";
+	print "Content-type: text/html; charset=UTF-8\n";
+	print "\n"; # End of HTTP headers
+	
+	print "Unsupported origin, request redirected to '$location'";
+	exit;
+}
+
+### Create graphs ###
+# Send HTTP headers
+_start_png_output();
+
+# Graphs are first grouped by their origin.
+# This is because some graph categories require special parameter handling.
+my $graphstatus = '';
+if($origin eq "entropy.cgi") {				## entropy.cgi
+	$graphstatus = Graphs::updateentropygraph($range);
+# ------
+
+} elsif($origin eq "hardwaregraphs.cgi") {	## hardwaregraphs.cgi
+	if($graph eq "hwtemp") {
+		$graphstatus = Graphs::updatehwtempgraph($range);
+	} elsif($graph eq "hwfan") {
+		$graphstatus = Graphs::updatehwfangraph($range);
+	} elsif($graph eq "hwvolt") {
+		$graphstatus = Graphs::updatehwvoltgraph($range);
+	} elsif($graph eq "thermaltemp") {
+		$graphstatus = Graphs::updatethermaltempgraph($range);
+	} elsif($graph =~ "sd?") {
+		$graphstatus = Graphs::updatehddgraph($graph, $range);
+	} elsif($graph =~ "nvme?") {
+		$graphstatus = Graphs::updatehddgraph($graph, $range);
+	} else {
+		$graphstatus = "Unknown graph name.";
+	}
+# ------
+
+} elsif($origin eq "media.cgi") {			## media.cgi
+	if ($graph =~ "sd?" || $graph =~ "mmcblk?" || $graph =~ "nvme?n?" || $graph =~ "xvd??" || $graph =~ "vd?" || $graph =~ "md*" ) {
+		$graphstatus = Graphs::updatediskgraph($graph, $range);
+	} else {
+		$graphstatus = "Unknown graph name.";
+	}
+# ------
+
+} elsif($origin eq "memory.cgi") {			## memory.cgi
+	if($graph eq "memory") {
+		$graphstatus = Graphs::updatememorygraph($range);
+	} elsif($graph eq "swap") {
+		$graphstatus = Graphs::updateswapgraph($range);
+	} else {
+		$graphstatus = "Unknown graph name.";
+	}
+# ------
+
+} elsif($origin eq "netexternal.cgi") {		## netexternal.cgi
+	$graphstatus = Graphs::updateifgraph($graph, $range);
+# ------
+
+} elsif($origin eq "netinternal.cgi") {		## netinternal.cgi
+	if ($graph =~ /wireless/){
+		$graph =~ s/wireless//g;
+		$graphstatus = Graphs::updatewirelessgraph($graph, $range);
+	} else {
+		$graphstatus = Graphs::updateifgraph($graph, $range);
+	}
+# ------
+
+} elsif($origin eq "netother.cgi") {		## netother.cgi
+	if($graph eq "conntrack") {
+		$graphstatus = Graphs::updateconntrackgraph($range);
+	} elsif($graph eq "fwhits") {
+		$graphstatus = Graphs::updatefwhitsgraph($range);
+	} else {
+		$graphstatus = Graphs::updatepinggraph($graph, $range);
+	}
+# ------
+
+} elsif($origin eq "netovpnrw.cgi") {		## netovpnrw.cgi
+	if($graph ne "UNDEF") {
+		$graphstatus = Graphs::updatevpngraph($graph, $range);
+	} else {
+		$graphstatus = "Unknown graph name.";
+	}
+# ------
+
+} elsif($origin eq "netovpnsrv.cgi") {		## netovpnsrv.cgi
+	if ($graph =~ /ipsec-/){
+		$graph =~ s/ipsec-//g;
+		$graphstatus = Graphs::updateifgraph($graph, $range);
+	} else {
+		$graphstatus = Graphs::updatevpnn2ngraph($graph, $range);
+	}
+# ------
+
+} elsif($origin eq "qos.cgi") { 			## qos.cgi
+	$graphstatus = Graphs::updateqosgraph($graph, $range);
+# ------
+
+} elsif($origin eq "services.cgi") {		## services.cgi
+	if($graph eq "processescpu") {
+		$graphstatus = Graphs::updateprocessescpugraph($range);
+	} elsif($graph eq "processesmemory") {
+		$graphstatus = Graphs::updateprocessesmemorygraph($range);
+	} else {
+		$graphstatus = "Unknown graph name.";
+	}
+# ------
+
+} elsif($origin eq "system.cgi") { 			## system.cgi
+	if($graph eq "cpu") {
+		$graphstatus = Graphs::updatecpugraph($range);
+	} elsif($graph eq "cpufreq") {
+		$graphstatus = Graphs::updatecpufreqgraph($range);
+	} elsif($graph eq "load") {
+		$graphstatus = Graphs::updateloadgraph($range);
+	} else {
+		$graphstatus = "Unknown graph name.";
+	}
+# ------
+
+} else {
+	$graphstatus = "Unknown graph origin.";
+}
+
+### Print error message ###
+# Add request parameters for debugging
+if($graphstatus) {
+	$graphstatus = "$graphstatus\n($origin, $graph, $range)";
+	_print_error($graphstatus);
+}
+
+###--- Internal functions ---###
+
+# Send HTTP headers and switch to binary output
+# (don't print any non-image data to STDOUT afterwards)
+sub _start_png_output {
+	print "Cache-Control: no-cache, no-store\n";
+	print "Content-Type: image/png\n";
+	print "\n"; # End of HTTP headers
+	binmode(STDOUT);
+}
+
+# Print error message to PNG output
+sub _print_error {
+	my ($message) = @_;
+	$message = "- Error -\n \n$message";
+
+	# Create new image with the same size as a graph
+	my $img = GD::Image->new($Graphs::image_size{'width'}, $Graphs::image_size{'height'});
+	$img->interlaced('true');
+
+	# Basic colors
+	my $color_background = $img->colorAllocate(255, 255, 255);
+	my $color_border = $img->colorAllocate(255, 0, 0);
+	my $color_text = $img->colorAllocate(0, 0, 0);
+
+	# Background and border
+	$img->setThickness(2);
+	$img->filledRectangle(0, 0, $img->width, $img->height, $color_background);
+	$img->rectangle(10, 10, $img->width - 10, $img->height - 10, $color_border);
+	
+	# Draw message with line-wrap
+	my $textbox = GD::Text::Wrap->new($img,
+		text => $message,
+		width => ($img->width - 50),
+		color => $color_text,
+		align => 'center',
+		line_space => 5,
+		preserve_nl => 1
+	);
+	$textbox->set_font(gdLargeFont);
+	$textbox->draw(25, 25);
+
+	# Get PNG output
+	print $img->png;
+}
diff --git a/html/html/include/rrdimage.js b/html/html/include/rrdimage.js
new file mode 100644
index 000000000..e7ee4c769
--- /dev/null
+++ b/html/html/include/rrdimage.js
@@ -0,0 +1,122 @@
+/*#############################################################################
+#                                                                             #
+# IPFire.org - A linux based firewall                                         #
+# Copyright (C) 2007-2021  IPFire Team  <info@ipfire.org>                     #
+#                                                                             #
+# This program is free software: you can redistribute it and/or modify        #
+# it under the terms of the GNU General Public License as published by        #
+# the Free Software Foundation, either version 3 of the License, or           #
+# (at your option) any later version.                                         #
+#                                                                             #
+# This program is distributed in the hope that it will be useful,             #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of              #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the               #
+# GNU General Public License for more details.                                #
+#                                                                             #
+# You should have received a copy of the GNU General Public License           #
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.       #
+#                                                                             #
+#############################################################################*/
+
+// "onclick" event handler for graph time range select button
+// buttonObj: reference to the button
+function rrdimage_selectRange(buttonObj) {
+	if(! (buttonObj && ('range' in buttonObj.dataset))) {
+		return; //required parameters are missing
+	}
+
+	// Get selected time range from button
+	const range = buttonObj.dataset.range;
+	
+	// Get surrounding div box and select new range 
+	let graphBox = $(buttonObj).closest('div');
+	_rrdimg_setRange(graphBox, range);
+}
+
+// Document loaded: Process all graphs, start reload timers
+$(function() {
+	$('div.rrdimage').each(function() {
+		let graphBox = $(this);
+		_rrdimg_setRange(graphBox, graphBox.data('defaultRange'), true);
+	});
+});
+
+//--- Internal functions ---
+
+// Set or update graph time range, start automatic reloading
+// graphBox: jQuery object, reference to graph div box
+// range: time range (day, hour, ...)
+// initMode: don't immediately reload graph, but force timers and attributes update
+function _rrdimg_setRange(graphBox, range, initMode = false) {
+	if(! ((graphBox instanceof jQuery) && (graphBox.length === 1))) {
+		return; //graphBox element missing
+	}
+
+	// Check range parameter, default to "day" on error
+	if(! ["hour", "day", "week", "month", "year"].includes(range)) {
+		range = "day";
+	}
+
+	// Check if the time range is changed
+	if((graphBox.data('range') !== range) || initMode) {
+		graphBox.data('range', range); //Store new range
+		
+		// Update button highlighting
+		graphBox.find('button').removeClass('selected');
+		graphBox.find(`button[data-range="${range}"]`).addClass('selected');
+	}
+
+	// Clear pending reload timer to prevent multiple image reloads
+	let timerId = graphBox.data('reloadTimer');
+	if(timerId !== undefined) {
+		window.clearInterval(timerId);
+		graphBox.removeData('reloadTimer');
+	}
+
+	// Determine auto reload interval (in seconds),
+	// interval = 0 disables auto reloading by default
+	let interval = 0;
+	switch(range) {
+		case 'hour':
+			interval = 60;
+			break;
+
+		case 'day':
+		case 'week':
+			interval = 300;
+			break;
+	}
+
+	// Start reload timer and store reference
+	if(interval > 0) {
+		timerId = window.setInterval(function(graphRef) {
+			_rrdimg_reload(graphRef);
+		}, interval * 1000, graphBox);
+		graphBox.data('reloadTimer', timerId);
+	}
+
+	// Always reload image unless disabled by init mode
+	if(! initMode) {
+		_rrdimg_reload(graphBox);
+	}
+}
+
+// Reload graph image, add timestamp to prevent caching
+// graphBox: jQuery object (graph element must be valid)
+function _rrdimg_reload(graphBox) {
+	const origin = graphBox.data('origin');
+	const graph = graphBox.data('graph');	
+	const timestamp = Date.now();
+
+	// Get user selected range or fall back to default
+	let range = graphBox.data('range');
+	if(! range) {
+		range = graphBox.data('defaultRange');
+	}
+
+	// Generate new image URL with timestamp
+	const imageUrl = `/cgi-bin/getrrdimage.cgi?origin=${origin}&graph=${graph}&range=${range}&timestamp=${timestamp}`;
+
+	// Get graph image and set new URL
+	graphBox.children('img').first().attr('src', imageUrl);
+}
