summaryrefslogtreecommitdiffstats
path: root/library/Graphite
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:44:18 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:44:18 +0000
commit23be945fd2810ee82e3a23cbcd2352c9bda43d4f (patch)
treedd511b321f308264952cffb005a4288ea4e478e6 /library/Graphite
parentInitial commit. (diff)
downloadicingaweb2-module-graphite-upstream.tar.xz
icingaweb2-module-graphite-upstream.zip
Adding upstream version 1.2.2.upstream/1.2.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--library/Graphite/Graphing/Chart.php385
-rw-r--r--library/Graphite/Graphing/GraphingTrait.php79
-rw-r--r--library/Graphite/Graphing/GraphiteWebClient.php198
-rw-r--r--library/Graphite/Graphing/MetricsDataSource.php48
-rw-r--r--library/Graphite/Graphing/MetricsQuery.php219
-rw-r--r--library/Graphite/Graphing/Template.php364
-rw-r--r--library/Graphite/Graphing/Templates.php321
-rw-r--r--library/Graphite/ProvidedHook/Icingadb/HostDetailExtension.php46
-rw-r--r--library/Graphite/ProvidedHook/Icingadb/IcingadbSupport.php9
-rw-r--r--library/Graphite/ProvidedHook/Icingadb/ServiceDetailExtension.php46
-rw-r--r--library/Graphite/ProvidedHook/Monitoring/DetailviewExtension.php40
-rw-r--r--library/Graphite/Util/IcingadbUtils.php49
-rw-r--r--library/Graphite/Util/InternalProcessTracker.php126
-rw-r--r--library/Graphite/Util/MacroTemplate.php239
-rw-r--r--library/Graphite/Util/TimeRangePickerTools.php111
-rw-r--r--library/Graphite/Web/Controller/IcingadbGraphiteController.php110
-rw-r--r--library/Graphite/Web/Controller/MonitoringAwareController.php175
-rw-r--r--library/Graphite/Web/Controller/TimeRangePickerTrait.php115
-rw-r--r--library/Graphite/Web/FakeSchemeRequest.php18
-rw-r--r--library/Graphite/Web/Form/Decorator/Proxy.php47
-rw-r--r--library/Graphite/Web/Form/Validator/CustomErrorMessagesValidator.php42
-rw-r--r--library/Graphite/Web/Form/Validator/HttpUserValidator.php30
-rw-r--r--library/Graphite/Web/Form/Validator/MacroTemplateValidator.php21
-rw-r--r--library/Graphite/Web/Widget/GraphImage.php143
-rw-r--r--library/Graphite/Web/Widget/Graphs.php688
-rw-r--r--library/Graphite/Web/Widget/Graphs/Host.php51
-rw-r--r--library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbHost.php61
-rw-r--r--library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbService.php71
-rw-r--r--library/Graphite/Web/Widget/Graphs/Service.php56
-rw-r--r--library/Graphite/Web/Widget/IcingadbGraphs.php106
-rw-r--r--library/Graphite/Web/Widget/InlineGraphImage.php49
31 files changed, 4063 insertions, 0 deletions
diff --git a/library/Graphite/Graphing/Chart.php b/library/Graphite/Graphing/Chart.php
new file mode 100644
index 0000000..ded8ae8
--- /dev/null
+++ b/library/Graphite/Graphing/Chart.php
@@ -0,0 +1,385 @@
+<?php
+
+namespace Icinga\Module\Graphite\Graphing;
+
+use Icinga\Module\Graphite\Util\MacroTemplate;
+use Icinga\Module\Graphite\Web\Widget\GraphImage;
+use Icinga\Web\Response;
+
+class Chart
+{
+ /**
+ * Used to render the chart
+ *
+ * @var GraphiteWebClient
+ */
+ protected $graphiteWebClient;
+
+ /** @var ?string This chart's background color */
+ protected $bgcolor;
+
+ /** @var ?string This chart's foreground color */
+ protected $fgcolor;
+
+ /** @var ?string This chart's major grid line color */
+ protected $majorGridLineColor;
+
+ /** @var ?string This chart's minor grid line color */
+ protected $minorGridLineColor;
+
+ /**
+ * This chart's base
+ *
+ * @var Template
+ */
+ protected $template;
+
+ /**
+ * Target metrics by curve name
+ *
+ * @var string[]
+ */
+ protected $metrics;
+
+ /**
+ * The chart's begin
+ *
+ * @var string
+ */
+ protected $from = '-14400';
+
+ /**
+ * The chart's end
+ *
+ * @var string
+ */
+ protected $until;
+
+ /**
+ * The chart's width
+ *
+ * @var int
+ */
+ protected $width = 350;
+
+ /**
+ * The chart's height
+ *
+ * @var int
+ */
+ protected $height = 200;
+
+ /**
+ * Whether to show the chart's legend
+ *
+ * @var bool
+ */
+ protected $showLegend = true;
+
+ /**
+ * Constructor
+ *
+ * @param GraphiteWebClient $graphiteWebClient Used to render the chart
+ * @param Template $template This chart's base
+ * @param string[] $metrics Target metrics by curve name
+ */
+ public function __construct(GraphiteWebClient $graphiteWebClient, Template $template, array $metrics)
+ {
+ $this->graphiteWebClient = $graphiteWebClient;
+ $this->template = $template;
+ $this->metrics = $metrics;
+ }
+
+ /**
+ * Let Graphite Web render this chart and serve the result immediately to the user agent (via the given response)
+ *
+ * Does not return.
+ *
+ * @param Response $response
+ */
+ public function serveImage(Response $response)
+ {
+ $image = new GraphImage($this);
+
+ // Errors should occur now or not at all
+ $image->render();
+
+ $response
+ ->setHeader('Content-Type', 'image/png', true)
+ ->setHeader('Content-Disposition', 'inline; filename="graph.png"', true)
+ ->setHeader('Cache-Control', null, true)
+ ->setHeader('Expires', null, true)
+ ->setHeader('Pragma', null, true)
+ ->setBody($image)
+ ->sendResponse();
+
+ exit;
+ }
+
+ /**
+ * Extract the values of the template's metrics filters' variables from the target metrics
+ *
+ * @return string[]
+ */
+ public function getMetricVariables()
+ {
+ /** @var MacroTemplate[][] $curves */
+ $curves = $this->template->getFullCurves();
+ $variables = [];
+
+ foreach ($this->metrics as $curveName => $metric) {
+ $vars = $curves[$curveName][0]->reverseResolve($metric);
+ if ($vars !== false) {
+ $variables = array_merge($variables, $vars);
+ }
+ }
+
+ return $variables;
+ }
+
+ /**
+ * Get Graphite Web client
+ *
+ * @return GraphiteWebClient
+ */
+ public function getGraphiteWebClient()
+ {
+ return $this->graphiteWebClient;
+ }
+
+ /**
+ * Get this chart's background color
+ *
+ * @return string|null
+ */
+ public function getBackgroundColor(): ?string
+ {
+ return $this->bgcolor;
+ }
+
+ /**
+ * Set this chart's background color
+ *
+ * @param string|null $color
+ *
+ * @return $this
+ */
+ public function setBackgroundColor(?string $color): self
+ {
+ $this->bgcolor = $color;
+
+ return $this;
+ }
+
+ /**
+ * Get this chart's foreground color
+ *
+ * @return string|null
+ */
+ public function getForegroundColor(): ?string
+ {
+ return $this->fgcolor;
+ }
+
+ /**
+ * Set this chart's foreground color
+ *
+ * @param string|null $color
+ *
+ * @return $this
+ */
+ public function setForegroundColor(?string $color): self
+ {
+ $this->fgcolor = $color;
+
+ return $this;
+ }
+
+ /**
+ * Get this graph's major grid line color
+ *
+ * @return string|null
+ */
+ public function getMajorGridLineColor(): ?string
+ {
+ return $this->majorGridLineColor;
+ }
+
+ /**
+ * Set this graph's major grid line color
+ *
+ * @param string|null $color
+ *
+ * @return $this
+ */
+ public function setMajorGridLineColor(?string $color): self
+ {
+ $this->majorGridLineColor = $color;
+
+ return $this;
+ }
+
+ /**
+ * Get this graph's minor grid line color
+ *
+ * @return string|null
+ */
+ public function getMinorGridLineColor(): ?string
+ {
+ return $this->minorGridLineColor;
+ }
+
+ /**
+ * Set this graph's minor grid line color
+ *
+ * @param string|null $color
+ *
+ * @return $this
+ */
+ public function setMinorGridLineColor(?string $color): self
+ {
+ $this->minorGridLineColor = $color;
+
+ return $this;
+ }
+
+ /**
+ * Get template
+ *
+ * @return Template
+ */
+ public function getTemplate()
+ {
+ return $this->template;
+ }
+
+ /**
+ * Get metrics
+ *
+ * @return string[]
+ */
+ public function getMetrics()
+ {
+ return $this->metrics;
+ }
+
+ /**
+ * Get begin
+ *
+ * @return string
+ */
+ public function getFrom()
+ {
+ return $this->from;
+ }
+
+ /**
+ * Set begin
+ *
+ * @param string $from
+ *
+ * @return $this
+ */
+ public function setFrom($from)
+ {
+ $this->from = $from;
+
+ return $this;
+ }
+
+ /**
+ * Get end
+ *
+ * @return string
+ */
+ public function getUntil()
+ {
+ return $this->until;
+ }
+
+ /**
+ * Set end
+ *
+ * @param string $until
+ *
+ * @return $this
+ */
+ public function setUntil($until)
+ {
+ $this->until = $until;
+
+ return $this;
+ }
+
+ /**
+ * Get width
+ *
+ * @return int
+ */
+ public function getWidth()
+ {
+ return $this->width;
+ }
+
+ /**
+ * Set width
+ *
+ * @param int $width
+ *
+ * @return $this
+ */
+ public function setWidth($width)
+ {
+ $this->width = $width;
+
+ return $this;
+ }
+
+ /**
+ * Get height
+ *
+ * @return int
+ */
+ public function getHeight()
+ {
+ return $this->height;
+ }
+
+ /**
+ * Set height
+ *
+ * @param int $height
+ *
+ * @return $this
+ */
+ public function setHeight($height)
+ {
+ $this->height = $height;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to show the chart's legend
+ *
+ * @return bool
+ */
+ public function getShowLegend()
+ {
+ return $this->showLegend;
+ }
+
+ /**
+ * Set whether to show the chart's legend
+ *
+ * @param bool $showLegend
+ *
+ * @return $this
+ */
+ public function setShowLegend($showLegend)
+ {
+ $this->showLegend = $showLegend;
+
+ return $this;
+ }
+}
diff --git a/library/Graphite/Graphing/GraphingTrait.php b/library/Graphite/Graphing/GraphingTrait.php
new file mode 100644
index 0000000..e32a52a
--- /dev/null
+++ b/library/Graphite/Graphing/GraphingTrait.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Icinga\Module\Graphite\Graphing;
+
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Graphite\Web\FakeSchemeRequest;
+use Icinga\Web\Url;
+
+trait GraphingTrait
+{
+ /**
+ * All loaded templates
+ *
+ * @var Templates
+ */
+ protected static $allTemplates;
+
+ /**
+ * Metrics data source
+ *
+ * @var MetricsDataSource
+ */
+ protected static $metricsDataSource;
+
+ /**
+ * Load and get all templates
+ *
+ * @return Templates
+ */
+ protected static function getAllTemplates()
+ {
+ if (static::$allTemplates === null) {
+ $allTemplates = (new Templates())->loadDir(
+ Icinga::app()
+ ->getModuleManager()
+ ->getModule('graphite')
+ ->getBaseDir() . DIRECTORY_SEPARATOR . 'templates'
+ );
+
+ $path = Config::resolvePath('modules/graphite/templates');
+ if (file_exists($path)) {
+ $allTemplates->loadDir($path);
+ }
+
+ static::$allTemplates = $allTemplates;
+ }
+
+ return static::$allTemplates;
+ }
+
+ /**
+ * Get metrics data source
+ *
+ * @return MetricsDataSource
+ *
+ * @throws ConfigurationError
+ */
+ public static function getMetricsDataSource()
+ {
+ if (static::$metricsDataSource === null) {
+ $config = Config::module('graphite');
+ $graphite = $config->getSection('graphite');
+ if (! isset($graphite->url)) {
+ throw new ConfigurationError('Missing "graphite.url" in "%s"', $config->getConfigFile());
+ }
+
+ static::$metricsDataSource = new MetricsDataSource(
+ (new GraphiteWebClient(Url::fromPath($graphite->url, [], new FakeSchemeRequest())))
+ ->setUser($graphite->user)
+ ->setPassword($graphite->password)
+ ->setInsecure($graphite->insecure)
+ );
+ }
+
+ return static::$metricsDataSource;
+ }
+}
diff --git a/library/Graphite/Graphing/GraphiteWebClient.php b/library/Graphite/Graphing/GraphiteWebClient.php
new file mode 100644
index 0000000..b06b6ce
--- /dev/null
+++ b/library/Graphite/Graphing/GraphiteWebClient.php
@@ -0,0 +1,198 @@
+<?php
+
+namespace Icinga\Module\Graphite\Graphing;
+
+use Icinga\Web\Url;
+use iplx\Http\Client;
+use iplx\Http\ClientInterface;
+use iplx\Http\Request;
+
+/**
+ * HTTP interface to Graphite Web
+ */
+class GraphiteWebClient
+{
+ /**
+ * Base URL of every Graphite Web HTTP request
+ *
+ * @var Url
+ */
+ protected $baseUrl;
+
+ /**
+ * HTTP basic auth user for every Graphite Web HTTP request
+ *
+ * @var string|null
+ */
+ protected $user;
+
+ /**
+ * The above user's password
+ *
+ * @var string|null
+ */
+ protected $password;
+
+ /**
+ * Don't verify the remote's TLS certificate
+ *
+ * @var bool
+ */
+ protected $insecure = false;
+
+ /**
+ * HTTP client
+ *
+ * @var ClientInterface
+ */
+ protected $httpClient;
+
+ /**
+ * Constructor
+ *
+ * @param Url $baseUrl Base URL of every Graphite Web HTTP request
+ */
+ public function __construct(Url $baseUrl)
+ {
+ $this->httpClient = new Client();
+
+ $this->setBaseUrl($baseUrl);
+ }
+
+ /**
+ * Send an HTTP request to the configured Graphite Web and return the response's body
+ *
+ * @param Url $url
+ * @param string $method
+ * @param string[] $headers
+ * @param string $body
+ *
+ * @return string
+ */
+ public function request(Url $url, $method = 'GET', array $headers = [], $body = null)
+ {
+ $headers['User-Agent'] = 'icingaweb2-module-graphite';
+ if ($this->user !== null) {
+ $headers['Authorization'] = 'Basic ' . base64_encode("{$this->user}:{$this->password}");
+ }
+
+ // TODO(ak): keep connections alive (TCP handshakes are a bit expensive and TLS handshakes are very expensive)
+ return (string) $this->httpClient->send(
+ new Request($method, $this->completeUrl($url)->getAbsoluteUrl(), $headers, $body),
+ ['curl' => [
+ CURLOPT_SSL_VERIFYPEER => ! $this->insecure
+ ]]
+ )->getBody();
+ }
+
+ /**
+ * Complete the given relative URL according to the base URL
+ *
+ * @param Url $url
+ *
+ * @return Url
+ */
+ public function completeUrl(Url $url)
+ {
+ $completeUrl = clone $this->baseUrl;
+ return $completeUrl
+ ->setPath(ltrim(rtrim($completeUrl->getPath(), '/') . '/' . ltrim($url->getPath(), '/'), '/'))
+ ->setParams($url->getParams());
+ }
+
+ /**
+ * Get the base URL of every Graphite Web HTTP request
+ *
+ * @return Url
+ */
+ public function getBaseUrl()
+ {
+ return $this->baseUrl;
+ }
+
+ /**
+ * Set the base URL of every Graphite Web HTTP request
+ *
+ * @param Url $baseUrl
+ *
+ * @return $this
+ */
+ public function setBaseUrl(Url $baseUrl)
+ {
+ $this->baseUrl = $baseUrl;
+
+ return $this;
+ }
+
+ /**
+ * Get the HTTP basic auth user
+ *
+ * @return null|string
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Set the HTTP basic auth user
+ *
+ * @param null|string $user
+ *
+ * @return $this
+ */
+ public function setUser($user)
+ {
+ $this->user = $user;
+
+ return $this;
+ }
+
+ /**
+ * Get the HTTP basic auth password
+ *
+ * @return null|string
+ */
+ public function getPassword()
+ {
+ return $this->password;
+ }
+
+ /**
+ * Set the HTTP basic auth password
+ *
+ * @param null|string $password
+ *
+ * @return $this
+ */
+ public function setPassword($password)
+ {
+ $this->password = $password;
+
+ return $this;
+ }
+
+ /**
+ * Get whether not to verify the remote's TLS certificate
+ *
+ * @return bool
+ */
+ public function getInsecure()
+ {
+ return $this->insecure;
+ }
+
+ /**
+ * Set whether not to verify the remote's TLS certificate
+ *
+ * @param bool $insecure
+ *
+ * @return $this
+ */
+ public function setInsecure($insecure = true)
+ {
+ $this->insecure = $insecure;
+
+ return $this;
+ }
+}
diff --git a/library/Graphite/Graphing/MetricsDataSource.php b/library/Graphite/Graphing/MetricsDataSource.php
new file mode 100644
index 0000000..19787da
--- /dev/null
+++ b/library/Graphite/Graphing/MetricsDataSource.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Graphite\Graphing;
+
+use Icinga\Data\Selectable;
+
+/**
+ * Provides an interface to Graphite Web's metrics list
+ */
+class MetricsDataSource implements Selectable
+{
+ /**
+ * HTTP interface to Graphite Web
+ *
+ * @var GraphiteWebClient
+ */
+ private $client;
+
+ /**
+ * Constructor
+ *
+ * @param GraphiteWebClient $client HTTP interface to Graphite Web
+ */
+ public function __construct(GraphiteWebClient $client)
+ {
+ $this->client = $client;
+ }
+
+ /**
+ * Initiate a new query
+ *
+ * @return MetricsQuery
+ */
+ public function select()
+ {
+ return new MetricsQuery($this);
+ }
+
+ /**
+ * Get the client passed to the constructor
+ *
+ * @return GraphiteWebClient
+ */
+ public function getClient()
+ {
+ return $this->client;
+ }
+}
diff --git a/library/Graphite/Graphing/MetricsQuery.php b/library/Graphite/Graphing/MetricsQuery.php
new file mode 100644
index 0000000..da05c17
--- /dev/null
+++ b/library/Graphite/Graphing/MetricsQuery.php
@@ -0,0 +1,219 @@
+<?php
+
+namespace Icinga\Module\Graphite\Graphing;
+
+use Icinga\Data\Fetchable;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filterable;
+use Icinga\Data\Queryable;
+use Icinga\Exception\NotImplementedError;
+use Icinga\Module\Graphite\GraphiteUtil;
+use Icinga\Module\Graphite\Util\IcingadbUtils;
+use Icinga\Module\Graphite\Util\MacroTemplate;
+use Icinga\Module\Graphite\Util\InternalProcessTracker as IPT;
+use Icinga\Module\Icingadb\Compat\UrlMigrator;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Monitoring\Object\Macro;
+use Icinga\Module\Monitoring\Object\MonitoredObject;
+use Icinga\Util\Json;
+use Icinga\Web\Url;
+use InvalidArgumentException;
+use ipl\Orm\Model;
+use ipl\Stdlib\Filter as IplFilter;
+
+/**
+ * Queries a {@link MetricsDataSource}
+ */
+class MetricsQuery implements Queryable, Filterable, Fetchable
+{
+ /**
+ * @var MetricsDataSource
+ */
+ protected $dataSource;
+
+ /**
+ * The base metrics pattern
+ *
+ * @var MacroTemplate
+ */
+ protected $base;
+
+ /**
+ * Extension of {@link base}
+ *
+ * @var string[]
+ */
+ protected $filter = [];
+
+ /**
+ * The object to render the graphs for
+ *
+ * @var MonitoredObject|Model
+ */
+ protected $object;
+
+ /**
+ * Constructor
+ *
+ * @param MetricsDataSource $dataSource
+ */
+ public function __construct(MetricsDataSource $dataSource)
+ {
+ $this->dataSource = $dataSource;
+ }
+
+ public function from($target, array $fields = null)
+ {
+ if ($fields !== null) {
+ throw new InvalidArgumentException('Fields are not applicable to this kind of query');
+ }
+
+ try {
+ $this->base = $target instanceof MacroTemplate ? $target : new MacroTemplate((string) $target);
+ } catch (InvalidArgumentException $e) {
+ throw new InvalidArgumentException('Bad target', $e);
+ }
+
+ return $this;
+ }
+
+ public function applyFilter(Filter $filter)
+ {
+ throw new NotImplementedError(__METHOD__);
+ }
+
+ public function setFilter(Filter $filter)
+ {
+ throw new NotImplementedError(__METHOD__);
+ }
+
+ public function getFilter()
+ {
+ throw new NotImplementedError(__METHOD__);
+ }
+
+ public function addFilter(Filter $filter)
+ {
+ throw new NotImplementedError(__METHOD__);
+ }
+
+ public function where($condition, $value = null)
+ {
+ $this->filter[$condition] = $this->escapeMetricStep($value);
+
+ return $this;
+ }
+
+ public function fetchAll()
+ {
+ $result = [];
+ foreach ($this->fetchColumn() as $metric) {
+ $result[] = (object) ['metric' => $metric];
+ }
+
+ return $result;
+ }
+
+ public function fetchRow()
+ {
+ $result = $this->fetchColumn();
+ return empty($result) ? false : (object) ['metric' => $result[0]];
+ }
+
+ public function fetchColumn()
+ {
+ $filter = [];
+ foreach ($this->base->getMacros() as $macro) {
+ if (isset($this->filter[$macro])) {
+ $filter[$macro] = $this->filter[$macro];
+ continue;
+ }
+
+ if (strpos($macro, '.') === false) {
+ continue;
+ }
+
+ $workaroundMacro = str_replace('.', '_', $macro);
+ if ($this->object instanceof Model) {
+ // icingadb macro
+ $tranformFilter = UrlMigrator::transformFilter(
+ IplFilter::equal($workaroundMacro, ''),
+ $this->object instanceof Host ? 'hosts' : 'services'
+ );
+
+ if ($tranformFilter === false) {
+ continue;
+ }
+
+ $migratedMacro = $tranformFilter->getColumn();
+
+ if ($migratedMacro === $workaroundMacro) {
+ $workaroundMacro = $macro;
+ } else {
+ $workaroundMacro = $migratedMacro;
+ }
+
+ $icingadbMacros = IcingadbUtils::getInstance();
+ $result = $icingadbMacros->resolveMacro($workaroundMacro, $this->object);
+ } else {
+ if ($workaroundMacro === 'service_name') {
+ $workaroundMacro = 'service_description';
+ }
+
+ $result = Macro::resolveMacro($workaroundMacro, $this->object);
+ }
+
+ if ($result !== $workaroundMacro) {
+ $filter[$macro] = $this->escapeMetricStep($result);
+ }
+ }
+
+ $client = $this->dataSource->getClient();
+ $url = Url::fromPath('metrics/expand', [
+ 'query' => $this->base->resolve($filter, '*')
+ ]);
+ $res = Json::decode($client->request($url));
+ natsort($res->results);
+
+ IPT::recordf('Fetched %s metric(s) from %s', count($res->results), (string) $client->completeUrl($url));
+
+ return array_values($res->results);
+ }
+
+ public function fetchOne()
+ {
+ $result = $this->fetchColumn();
+ return empty($result) ? false : $result[0];
+ }
+
+ public function fetchPairs()
+ {
+ throw new NotImplementedError(__METHOD__);
+ }
+
+ /**
+ * Set the object to render the graphs for
+ *
+ * @param MonitoredObject|Model $object
+ *
+ * @return $this
+ */
+ public function setObject($object)
+ {
+ $this->object = $object;
+
+ return $this;
+ }
+
+ /**
+ * Escapes a string for usage in a Graphite metric path between two dots
+ *
+ * @param string $step
+ *
+ * @return string
+ */
+ protected function escapeMetricStep($step)
+ {
+ return preg_replace('/[^a-zA-Z0-9\*\-:^[\]$#%\']/', '_', $step);
+ }
+}
diff --git a/library/Graphite/Graphing/Template.php b/library/Graphite/Graphing/Template.php
new file mode 100644
index 0000000..a030fb7
--- /dev/null
+++ b/library/Graphite/Graphing/Template.php
@@ -0,0 +1,364 @@
+<?php
+
+namespace Icinga\Module\Graphite\Graphing;
+
+use Icinga\Application\Config;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Graphite\Util\MacroTemplate;
+use Icinga\Module\Graphite\Util\InternalProcessTracker as IPT;
+use Icinga\Module\Monitoring\Object\MonitoredObject;
+use InvalidArgumentException;
+use ipl\Orm\Model;
+
+class Template
+{
+ /**
+ * The configured icinga.graphite_writer_host_name_template
+ *
+ * @var MacroTemplate
+ */
+ protected static $hostNameTemplate;
+
+ /**
+ * The configured icinga.graphite_writer_service_name_template
+ *
+ * @var MacroTemplate
+ */
+ protected static $serviceNameTemplate;
+
+ /**
+ * All curves to show in a chart by name with Graphite Web metric filters and Graphite functions
+ *
+ * [$curve => [$metricFilter, $function], ...]
+ *
+ * @var MacroTemplate[][]
+ */
+ protected $curves = [];
+
+ /**
+ * All curves to show in a chart by name with full Graphite Web metric filters and Graphite functions
+ *
+ * [$curve => [$metricFilter, $function], ...]
+ *
+ * @var MacroTemplate[][]
+ */
+ protected $fullCurves;
+
+ /**
+ * Additional URL parameters for rendering via Graphite Web
+ *
+ * [$key => $value, ...]
+ *
+ * @var MacroTemplate[]
+ */
+ protected $urlParams = [];
+
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ }
+
+ /**
+ * Get all charts based on this template and applicable to the metrics
+ * from the given data source restricted by the given filter
+ *
+ * @param MetricsDataSource $dataSource
+ * @param MonitoredObject|Model $object The object to render the graphs for
+ * @param string[] $filter
+ * @param MacroTemplate[] $excludeMetrics
+ *
+ * @return Chart[]
+ */
+ public function getCharts(
+ MetricsDataSource $dataSource,
+ $object,
+ array $filter,
+ array &$excludeMetrics = []
+ ) {
+ $metrics = [];
+ $metricsUsed = 0;
+ $metricsExcluded = 0;
+
+ foreach ($this->getFullCurves() as $curveName => $curve) {
+ $fullMetricTemplate = $curve[0];
+
+ $query = $dataSource->select()->setObject($object)->from($fullMetricTemplate);
+
+ foreach ($filter as $key => $value) {
+ $query->where($key, $value);
+ }
+
+ foreach ($query->fetchColumn() as $metric) {
+ foreach ($excludeMetrics as $excludeMetric) {
+ if ($excludeMetric->reverseResolve($metric) !== false) {
+ ++$metricsExcluded;
+ continue 2;
+ }
+ }
+
+ $vars = $curve[0]->reverseResolve($metric);
+ if ($vars !== false) {
+ $metrics[$curveName][$metric] = $vars;
+ ++$metricsUsed;
+ }
+ }
+ }
+
+ switch (count($metrics)) {
+ case 0:
+ $metricsCombinations = [];
+ break;
+
+ case 1:
+ $metricsCombinations = [];
+
+ foreach ($metrics as $curveName => & $curveMetrics) {
+ foreach ($curveMetrics as $metric => & $_) {
+ $metricsCombinations[] = [$curveName => $metric];
+ }
+ unset($_);
+ }
+ unset($curveMetrics);
+
+ break;
+
+ default:
+ $possibleCombinations = [];
+ $handledCurves = [];
+ foreach ($metrics as $curveName1 => & $metrics1) {
+ $handledCurves[$curveName1] = true;
+
+ foreach ($metrics as $curveName2 => & $metrics2) {
+ if (! isset($handledCurves[$curveName2])) {
+ foreach ($metrics1 as $metric1 => & $vars1) {
+ foreach ($metrics2 as $metric2 => & $vars2) {
+ if (
+ count(array_intersect_assoc($vars1, $vars2))
+ === count(array_intersect_key($vars1, $vars2))
+ ) {
+ $possibleCombinations[$curveName1][$curveName2][$metric1][$metric2] = true;
+ }
+ }
+ unset($vars2);
+ }
+ unset($vars1);
+ }
+ }
+ unset($metrics2);
+ }
+ unset($metrics1);
+
+ $metricsCombinations = [];
+ $this->combineMetrics($metrics, $possibleCombinations, $metricsCombinations);
+ }
+
+ $charts = [];
+ foreach ($metricsCombinations as $metricsCombination) {
+ $charts[] = new Chart($dataSource->getClient(), $this, $metricsCombination);
+ }
+
+ IPT::recordf('Excluded %s metric(s)', $metricsExcluded);
+ IPT::recordf('Combined %s metric(s) to %s chart(s)', $metricsUsed, count($charts));
+
+ return $charts;
+ }
+
+ /**
+ * Fill the given metrics combinations from the given metrics as restricted by the given possible combinations
+ *
+ * @param string[][][] $metrics
+ * @param bool[][][][] $possibleCombinations
+ * @param string[][] $metricsCombinations
+ * @param string[] $currentCombination
+ */
+ protected function combineMetrics(
+ array &$metrics,
+ array &$possibleCombinations,
+ array &$metricsCombinations,
+ array $currentCombination = []
+ ) {
+ if (empty($currentCombination)) {
+ foreach ($metrics as $curveName => & $curveMetrics) {
+ foreach ($curveMetrics as $metric => & $_) {
+ $this->combineMetrics(
+ $metrics,
+ $possibleCombinations,
+ $metricsCombinations,
+ [$curveName => $metric]
+ );
+ }
+ unset($_);
+
+ break;
+ }
+ unset($curveMetrics);
+ } elseif (count($currentCombination) === count($metrics)) {
+ $metricsCombinations[] = $currentCombination;
+ } else {
+ foreach ($metrics as $nextCurveName => & $_) {
+ if (! isset($currentCombination[$nextCurveName])) {
+ break;
+ }
+ }
+ unset($_);
+
+ $allowedNextCurveMetricsPerCurrentCurveName = [];
+ foreach ($currentCombination as $currentCurveName => $currentCurveMetric) {
+ $allowedNextCurveMetricsPerCurrentCurveName[$currentCurveName]
+ = $possibleCombinations[$currentCurveName][$nextCurveName][$currentCurveMetric];
+ }
+
+ $allowedNextCurveMetrics = $allowedNextCurveMetricsPerCurrentCurveName[$currentCurveName];
+ unset($allowedNextCurveMetricsPerCurrentCurveName[$currentCurveName]);
+
+ foreach ($allowedNextCurveMetricsPerCurrentCurveName as & $allowedMetrics) {
+ $allowedNextCurveMetrics = array_intersect_key($allowedNextCurveMetrics, $allowedMetrics);
+ }
+ unset($allowedMetrics);
+
+ foreach ($allowedNextCurveMetrics as $allowedNextCurveMetric => $_) {
+ $nextCombination = $currentCombination;
+ $nextCombination[$nextCurveName] = $allowedNextCurveMetric;
+
+ $this->combineMetrics($metrics, $possibleCombinations, $metricsCombinations, $nextCombination);
+ }
+ }
+ }
+
+ /**
+ * Get curves to show in a chart by name with Graphite Web metric filters and Graphite functions
+ *
+ * @return MacroTemplate[][]
+ */
+ public function getCurves()
+ {
+ return $this->curves;
+ }
+
+ /**
+ * Get curves to show in a chart by name with full Graphite Web metric filters and Graphite functions
+ *
+ * @return MacroTemplate[][]
+ */
+ public function getFullCurves()
+ {
+ if ($this->fullCurves === null) {
+ $curves = $this->curves;
+
+ foreach ($curves as &$curve) {
+ $curve[0] = new MacroTemplate($curve[0]->resolve([
+ 'host_name_template' => static::getHostNameTemplate(),
+ 'service_name_template' => static::getServiceNameTemplate(),
+ '' => '$$'
+ ]));
+ }
+ unset($curve);
+
+ $this->fullCurves = $curves;
+ }
+
+ return $this->fullCurves;
+ }
+
+ /**
+ * Set curves to show in a chart by name with Graphite Web metric filters and Graphite functions
+ *
+ * @param MacroTemplate[][] $curves
+ *
+ * @return $this
+ */
+ public function setCurves(array $curves)
+ {
+ $this->curves = $curves;
+
+ return $this;
+ }
+
+ /**
+ * Get additional URL parameters for Graphite Web
+ *
+ * @return MacroTemplate[]
+ */
+ public function getUrlParams()
+ {
+ return $this->urlParams;
+ }
+
+ /**
+ * Set additional URL parameters for Graphite Web
+ *
+ * @param MacroTemplate[] $urlParams
+ *
+ * @return $this
+ */
+ public function setUrlParams(array $urlParams)
+ {
+ $this->urlParams = $urlParams;
+
+ return $this;
+ }
+
+ /**
+ * Get {@link hostNameTemplate}
+ *
+ * @return MacroTemplate
+ *
+ * @throws ConfigurationError If the configuration is invalid
+ */
+ protected static function getHostNameTemplate()
+ {
+ if (static::$hostNameTemplate === null) {
+ $config = Config::module('graphite');
+ $template = $config->get(
+ 'icinga',
+ 'graphite_writer_host_name_template',
+ 'icinga2.$host.name$.host.$host.check_command$'
+ );
+
+ try {
+ static::$hostNameTemplate = new MacroTemplate($template);
+ } catch (InvalidArgumentException $e) {
+ throw new ConfigurationError(
+ 'Bad icinga.graphite_writer_host_name_template in "%s": %s',
+ $config->getConfigFile(),
+ $e->getMessage()
+ );
+ }
+ }
+
+ return static::$hostNameTemplate;
+ }
+
+ /**
+ * Get {@link serviceNameTemplate}
+ *
+ * @return MacroTemplate
+ *
+ * @throws ConfigurationError If the configuration is invalid
+ */
+ protected static function getServiceNameTemplate()
+ {
+ if (static::$serviceNameTemplate === null) {
+ $config = Config::module('graphite');
+ $template = $config->get(
+ 'icinga',
+ 'graphite_writer_service_name_template',
+ 'icinga2.$host.name$.services.$service.name$.$service.check_command$'
+ );
+
+ try {
+ static::$serviceNameTemplate = new MacroTemplate($template);
+ } catch (InvalidArgumentException $e) {
+ throw new ConfigurationError(
+ 'Bad icinga.graphite_writer_service_name_template in "%s": %s',
+ $config->getConfigFile(),
+ $e->getMessage()
+ );
+ }
+ }
+
+ return static::$serviceNameTemplate;
+ }
+}
diff --git a/library/Graphite/Graphing/Templates.php b/library/Graphite/Graphing/Templates.php
new file mode 100644
index 0000000..0765e46
--- /dev/null
+++ b/library/Graphite/Graphing/Templates.php
@@ -0,0 +1,321 @@
+<?php
+
+namespace Icinga\Module\Graphite\Graphing;
+
+use Icinga\Application\Config;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Graphite\Util\MacroTemplate;
+use Icinga\Web\UrlParams;
+use InvalidArgumentException;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use SplFileInfo;
+
+/**
+ * Templates collection
+ */
+class Templates
+{
+ /**
+ * All templates by their check command and name
+ *
+ * @var Template[][]
+ */
+ protected $templates = [];
+
+ /**
+ * All default templates by their name
+ *
+ * @var Template[]
+ */
+ protected $defaultTemplates = [];
+
+ /**
+ * Default URL params for all templates
+ *
+ * @var string[]
+ */
+ protected $defaultUrlParams = [];
+
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ $config = Config::module('graphite');
+
+ foreach ($config->getSection('default_url_params') as $param => $value) {
+ try {
+ $this->defaultUrlParams[$param] = new MacroTemplate($value);
+ } catch (InvalidArgumentException $e) {
+ throw new ConfigurationError(
+ 'Invalid URL parameter "%s" ("%s") in file "%s"',
+ $param,
+ $value,
+ $config->getConfigFile(),
+ $e
+ );
+ }
+ }
+ }
+
+ /**
+ * Load templates as configured inside the given directory
+ *
+ * @param string $path
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError If the configuration is invalid
+ */
+ public function loadDir($path)
+ {
+ foreach (
+ new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator(
+ $path,
+ RecursiveDirectoryIterator::KEY_AS_PATHNAME | RecursiveDirectoryIterator::CURRENT_AS_FILEINFO
+ | RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS
+ ),
+ RecursiveIteratorIterator::LEAVES_ONLY
+ ) as $filepath => $fileinfo
+ ) {
+ /** @var SplFileInfo $fileinfo */
+
+ if ($fileinfo->isFile() && preg_match('/\A[^.].*\.ini\z/si', $fileinfo->getFilename())) {
+ $this->loadIni($filepath);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Load templates as configured in the given INI file
+ *
+ * @param string $path
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError If the configuration is invalid
+ */
+ public function loadIni($path)
+ {
+ /** @var string[][][] $templates */
+ $templates = [];
+
+ foreach (Config::fromIni($path) as $section => $options) {
+ /** @var ConfigObject $options */
+
+ $matches = [];
+ if (! preg_match('/\A(.+)\.(graph|metrics_filters|urlparams|functions)\z/', $section, $matches)) {
+ throw new ConfigurationError('Bad section name "%s" in file "%s"', $section, $path);
+ }
+
+ $templates[$matches[1]][$matches[2]] = $options->toArray();
+ }
+
+ $checkCommands = [];
+
+ foreach ($templates as $templateName => $template) {
+ $checkCommands[$templateName] = isset($template['graph']['check_command'])
+ ? array_unique(preg_split('/\s*,\s*/', $template['graph']['check_command'], -1, PREG_SPLIT_NO_EMPTY))
+ : [];
+ unset($template['graph']['check_command']);
+
+ if (isset($template['graph'])) {
+ switch (count($template['graph'])) {
+ case 0:
+ break;
+
+ case 1:
+ throw new ConfigurationError(
+ 'Bad option for template "%s" in file "%s": "graph.%s"',
+ $templateName,
+ $path,
+ array_keys($template['graph'])[0]
+ );
+
+ default:
+ $standalone = array_keys($template['graph']);
+ sort($standalone);
+
+ throw new ConfigurationError(
+ 'Bad options for template "%s" in file "%s": %s',
+ $templateName,
+ $path,
+ implode(', ', array_map(
+ function ($option) {
+ return "\"graph.$option\"";
+ },
+ $standalone
+ ))
+ );
+ }
+ }
+
+ /** @var MacroTemplate[][] $curves */
+ $curves = [];
+
+ if (isset($template['metrics_filters'])) {
+ foreach ($template['metrics_filters'] as $curve => $metricsFilter) {
+ try {
+ $curves[$curve][0] = new MacroTemplate($metricsFilter);
+ } catch (InvalidArgumentException $e) {
+ throw new ConfigurationError(
+ 'Bad metrics filter "%s" for curve "%s" of template "%s" in file "%s": %s',
+ $metricsFilter,
+ $curve,
+ $templateName,
+ $path,
+ $e->getMessage()
+ );
+ }
+
+ if (
+ count(array_intersect(
+ $curves[$curve][0]->getMacros(),
+ ['host_name_template', 'service_name_template']
+ )) !== 1
+ ) {
+ throw new ConfigurationError(
+ 'Bad metrics filter "%s" for curve "%s" of template "%s" in file "%s": must include'
+ . ' either the macro $host_name_template$ or $service_name_template$, but not both',
+ $metricsFilter,
+ $curve,
+ $templateName,
+ $path
+ );
+ }
+
+ if (isset($template['functions'][$curve])) {
+ try {
+ $curves[$curve][1] = new MacroTemplate($template['functions'][$curve]);
+ } catch (InvalidArgumentException $e) {
+ throw new ConfigurationError(
+ 'Bad function "%s" for curve "%s" of template "%s" in file "%s": %s',
+ $template['functions'][$curve],
+ $curve,
+ $templateName,
+ $path,
+ $e->getMessage()
+ );
+ }
+
+ unset($template['functions'][$curve]);
+ } else {
+ $curves[$curve][1] = new MacroTemplate('$metric$');
+ }
+ }
+ }
+
+ if (isset($template['functions'])) {
+ switch (count($template['functions'])) {
+ case 0:
+ break;
+
+ case 1:
+ throw new ConfigurationError(
+ 'Metrics filter for curve "%s" of template "%s" in file "%s" missing',
+ array_keys($template['functions'])[0],
+ $templateName,
+ $path
+ );
+
+ default:
+ $standalone = array_keys($template['functions']);
+ sort($standalone);
+
+ throw new ConfigurationError(
+ 'Metrics filter for curves of template "%s" in file "%s" missing: "%s"',
+ $templateName,
+ $path,
+ implode('", "', $standalone)
+ );
+ }
+ }
+
+ $urlParams = $this->defaultUrlParams;
+
+ if (isset($template['urlparams'])) {
+ foreach ($template['urlparams'] as $key => $value) {
+ try {
+ $urlParams[$key] = new MacroTemplate($value);
+ } catch (InvalidArgumentException $e) {
+ throw new ConfigurationError(
+ 'Invalid URL parameter "%s" ("%s") for template "%s" in file "%s": %s',
+ $key,
+ $value,
+ $templateName,
+ $path,
+ $e->getMessage()
+ );
+ }
+ }
+ }
+
+ $templates[$templateName] = empty($curves) ? null : (new Template())
+ ->setCurves($curves)
+ ->setUrlParams($urlParams);
+ }
+
+ foreach ($templates as $templateName => $template) {
+ if ($template === null) {
+ if (empty($checkCommands[$templateName])) {
+ unset($this->defaultTemplates[$templateName]);
+ } else {
+ foreach ($checkCommands[$templateName] as $checkCommand) {
+ unset($this->templates[$checkCommand][$templateName]);
+
+ if (empty($this->templates[$checkCommand])) {
+ unset($this->templates[$checkCommand]);
+ }
+ }
+ }
+ } else {
+ if (empty($checkCommands[$templateName])) {
+ $this->defaultTemplates[$templateName] = $template;
+ } else {
+ foreach ($checkCommands[$templateName] as $checkCommand) {
+ $this->templates[$checkCommand][$templateName] = $template;
+ }
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get all loaded templates for the given check command by their names
+ *
+ * @param string $checkCommand
+ *
+ * @return Template[]
+ */
+ public function getTemplates($checkCommand)
+ {
+ return isset($this->templates[$checkCommand]) ? $this->templates[$checkCommand] : [];
+ }
+
+ /**
+ * Get all loaded templates for all check commands
+ *
+ * @return Template[][]
+ */
+ public function getAllTemplates()
+ {
+ return $this->templates;
+ }
+
+ /**
+ * Get all loaded default templates by their names
+ *
+ * @return Template[]
+ */
+ public function getDefaultTemplates()
+ {
+ return $this->defaultTemplates;
+ }
+}
diff --git a/library/Graphite/ProvidedHook/Icingadb/HostDetailExtension.php b/library/Graphite/ProvidedHook/Icingadb/HostDetailExtension.php
new file mode 100644
index 0000000..31e4e6c
--- /dev/null
+++ b/library/Graphite/ProvidedHook/Icingadb/HostDetailExtension.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Icinga\Module\Graphite\ProvidedHook\Icingadb;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Graphite\Util\InternalProcessTracker as IPT;
+use Icinga\Module\Graphite\Web\Controller\TimeRangePickerTrait;
+use Icinga\Module\Graphite\Web\Widget\Graphs;
+use Icinga\Module\Icingadb\Hook\HostDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Html\ValidHtml;
+
+class HostDetailExtension extends HostDetailExtensionHook
+{
+ use TimeRangePickerTrait;
+
+ public function getHtmlForObject(Host $host): ValidHtml
+ {
+ if (Icinga::app()->getRequest()->getUrl()->getParam('graph_debug')) {
+ IPT::enable();
+ }
+
+ $graphs = (string) Graphs::forIcingadbObject($host)
+ ->setWidth(440)
+ ->setHeight(220)
+ ->setClasses(['object-detail-view'])
+ ->setPreloadDummy()
+ ->setShowNoGraphsFound(false)
+ ->handleRequest();
+
+ if (! empty($graphs)) {
+ $this->handleTimeRangePickerRequest();
+
+ $header = Html::tag('h2', [], 'Graphs');
+ $timepicker = HtmlString::create($this->renderTimeRangePicker(Icinga::app()->getViewRenderer()->view));
+ $graphColorRegistry = Html::tag('div', ['class' => 'graphite-graph-color-registry']);
+ $graphs = HtmlString::create($graphs);
+
+ return HtmlString::create($header . $timepicker . $graphColorRegistry . $graphs);
+ }
+
+ return HtmlString::create('');
+ }
+}
diff --git a/library/Graphite/ProvidedHook/Icingadb/IcingadbSupport.php b/library/Graphite/ProvidedHook/Icingadb/IcingadbSupport.php
new file mode 100644
index 0000000..8f0f38e
--- /dev/null
+++ b/library/Graphite/ProvidedHook/Icingadb/IcingadbSupport.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Graphite\ProvidedHook\Icingadb;
+
+use Icinga\Module\Icingadb\Hook\IcingadbSupportHook;
+
+class IcingadbSupport extends IcingadbSupportHook
+{
+}
diff --git a/library/Graphite/ProvidedHook/Icingadb/ServiceDetailExtension.php b/library/Graphite/ProvidedHook/Icingadb/ServiceDetailExtension.php
new file mode 100644
index 0000000..63c2b79
--- /dev/null
+++ b/library/Graphite/ProvidedHook/Icingadb/ServiceDetailExtension.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Icinga\Module\Graphite\ProvidedHook\Icingadb;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Graphite\Util\InternalProcessTracker as IPT;
+use Icinga\Module\Graphite\Web\Controller\TimeRangePickerTrait;
+use Icinga\Module\Graphite\Web\Widget\Graphs;
+use Icinga\Module\Icingadb\Hook\ServiceDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Html\ValidHtml;
+
+class ServiceDetailExtension extends ServiceDetailExtensionHook
+{
+ use TimeRangePickerTrait;
+
+ public function getHtmlForObject(Service $service): ValidHtml
+ {
+ if (Icinga::app()->getRequest()->getUrl()->getParam('graph_debug')) {
+ IPT::enable();
+ }
+
+ $graphs = (string) Graphs::forIcingadbObject($service)
+ ->setWidth(440)
+ ->setHeight(220)
+ ->setClasses(['object-detail-view'])
+ ->setPreloadDummy()
+ ->setShowNoGraphsFound(false)
+ ->handleRequest();
+
+ if (! empty($graphs)) {
+ $this->handleTimeRangePickerRequest();
+
+ $header = Html::tag('h2', [], 'Graphs');
+ $timepicker = HtmlString::create($this->renderTimeRangePicker(Icinga::app()->getViewRenderer()->view));
+ $graphColorRegistry = Html::tag('div', ['class' => 'graphite-graph-color-registry']);
+ $graphs = HtmlString::create($graphs);
+
+ return HtmlString::create($header . $timepicker . $graphColorRegistry . $graphs);
+ }
+
+ return HtmlString::create('');
+ }
+}
diff --git a/library/Graphite/ProvidedHook/Monitoring/DetailviewExtension.php b/library/Graphite/ProvidedHook/Monitoring/DetailviewExtension.php
new file mode 100644
index 0000000..d6a4673
--- /dev/null
+++ b/library/Graphite/ProvidedHook/Monitoring/DetailviewExtension.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Graphite\ProvidedHook\Monitoring;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Graphite\Util\InternalProcessTracker as IPT;
+use Icinga\Module\Graphite\Web\Controller\TimeRangePickerTrait;
+use Icinga\Module\Graphite\Web\Widget\Graphs;
+use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook;
+use Icinga\Module\Monitoring\Object\MonitoredObject;
+
+class DetailviewExtension extends DetailviewExtensionHook
+{
+ use TimeRangePickerTrait;
+
+ public function getHtmlForObject(MonitoredObject $object)
+ {
+ if (Icinga::app()->getRequest()->getUrl()->getParam('graph_debug')) {
+ IPT::enable();
+ }
+
+ $graphs = (string) Graphs::forMonitoredObject($object)
+ ->setWidth(440)
+ ->setHeight(220)
+ ->setClasses(['object-detail-view'])
+ ->setPreloadDummy()
+ ->setShowNoGraphsFound(false)
+ ->handleRequest();
+
+ if ($graphs !== '') {
+ $this->handleTimeRangePickerRequest();
+ return '<h2>' . mt('graphite', 'Graphs') . '</h2>'
+ . $this->renderTimeRangePicker($this->getView())
+ . '<div class="graphite-graph-color-registry"></div>'
+ . $graphs;
+ }
+
+ return '';
+ }
+}
diff --git a/library/Graphite/Util/IcingadbUtils.php b/library/Graphite/Util/IcingadbUtils.php
new file mode 100644
index 0000000..43334e5
--- /dev/null
+++ b/library/Graphite/Util/IcingadbUtils.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga Graphite Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Graphite\Util;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\Macros;
+
+/**
+* Class for initialising icingadb utils
+ */
+class IcingadbUtils
+{
+ use Macros;
+ use Database;
+ use Auth;
+
+ protected static $instance;
+
+ /**
+ * @see getInstance()
+ */
+ private function __construct()
+ {
+ }
+
+ /**
+ * Get the IcingadbUtils instance
+ *
+ * @return IcingadbUtils
+ */
+ public static function getInstance(): IcingadbUtils
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+
+ return static::$instance;
+ }
+
+ /**
+ * Prevent the instance from being cloned (which would create a second instance of it)
+ */
+ private function __clone()
+ {
+ }
+}
diff --git a/library/Graphite/Util/InternalProcessTracker.php b/library/Graphite/Util/InternalProcessTracker.php
new file mode 100644
index 0000000..f7f2df6
--- /dev/null
+++ b/library/Graphite/Util/InternalProcessTracker.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Icinga\Module\Graphite\Util;
+
+use Icinga\Authentication\Auth;
+use Icinga\Security\SecurityException;
+
+/**
+ * A record about what happened during a specific action
+ */
+class InternalProcessTracker
+{
+ /**
+ * Whether to record anything
+ *
+ * @var bool
+ */
+ private static $enabled = false;
+
+ /**
+ * How many '+'es to prepend to each new record
+ *
+ * @var int
+ */
+ private static $indentation = 1;
+
+ /**
+ * The recorded happenings
+ *
+ * @var string[]
+ */
+ private static $records = [];
+
+ /**
+ * Get whether recording is enabled
+ *
+ * @return bool
+ */
+ public static function enabled()
+ {
+ return self::$enabled;
+ }
+
+ /**
+ * Enable recording
+ *
+ * @throws SecurityException
+ */
+ public static function enable()
+ {
+ if (! Auth::getInstance()->hasPermission('graphite/debug')) {
+ throw new SecurityException('No permission for graphite/debug');
+ }
+
+ self::$enabled = true;
+ }
+
+ /**
+ * Introduce a "sub-process"
+ */
+ public static function indent()
+ {
+ if (self::$enabled) {
+ ++self::$indentation;
+ }
+ }
+
+ /**
+ * Record a happening
+ *
+ * Behaves like {@link sprintf()} if additional arguments given, but {@link var_export()}s the arguments first
+ * (so always use %s instead of e.g. %d).
+ *
+ * @param string $format
+ */
+ public static function recordf($format)
+ {
+ if (self::$enabled) {
+ if (func_num_args() > 1) {
+ $args = [];
+ foreach (array_slice(func_get_args(), 1) as $arg) {
+ $args[] = var_export($arg, true);
+ }
+
+ $format = vsprintf($format, $args);
+ }
+
+ self::$records[] = str_repeat('+', self::$indentation) . " $format";
+ }
+ }
+
+ /**
+ * Terminate a "sub-process"
+ */
+ public static function unindent()
+ {
+ if (self::$enabled) {
+ --self::$indentation;
+ }
+ }
+
+ /**
+ * Dump everything recorded as plain text
+ *
+ * @return string
+ */
+ public static function dump()
+ {
+ return implode("\n", self::$records);
+ }
+
+ /**
+ * Reset records
+ */
+ public static function clear()
+ {
+ if (self::$enabled) {
+ self::$indentation = 1;
+ self::$records = [];
+ }
+ }
+
+ final private function __construct()
+ {
+ }
+}
diff --git a/library/Graphite/Util/MacroTemplate.php b/library/Graphite/Util/MacroTemplate.php
new file mode 100644
index 0000000..23a171a
--- /dev/null
+++ b/library/Graphite/Util/MacroTemplate.php
@@ -0,0 +1,239 @@
+<?php
+
+namespace Icinga\Module\Graphite\Util;
+
+use InvalidArgumentException;
+
+/**
+ * A macro-based template for strings
+ */
+class MacroTemplate
+{
+ /**
+ * Macros' start and end character
+ *
+ * @var string
+ */
+ protected $macroCharacter;
+
+ /**
+ * The parsed template
+ *
+ * @var string[]
+ */
+ protected $template;
+
+ /**
+ * Regex for reverse resolving patterns
+ *
+ * @var string
+ */
+ protected $reverseResolvePattern;
+
+ /**
+ * Wildcards
+ *
+ * @var string[]
+ */
+ protected $wildCards;
+
+ /**
+ * The original raw template
+ *
+ * @var string
+ */
+ protected $orgTemplate;
+
+ /**
+ * Constructor
+ *
+ * @param string $template The raw template
+ * @param string $macroCharacter Macros' start and end character
+ */
+ public function __construct($template, $macroCharacter = '$')
+ {
+ $this->orgTemplate = $template;
+ $this->macroCharacter = $macroCharacter;
+ $this->template = explode($macroCharacter, $template);
+ foreach ($this->template as $key => $value) {
+ if (preg_match('/([^:]+):(.+)/', $value, $match)) {
+ $wildCardKey = $match[1];
+ $this->template[$key] = $wildCardKey;
+ $this->wildCards[$wildCardKey] = $match[2];
+ }
+ }
+
+ if (! (count($this->template) % 2)) {
+ throw new InvalidArgumentException(
+ 'template contains odd number of ' . var_export($macroCharacter, true)
+ . 's: ' . var_export($template, true)
+ );
+ }
+ }
+
+ /**
+ * Return a string based on this template with the macros resolved from the given variables
+ *
+ * @param string[] $variables
+ * @param string $default The default value for missing variables.
+ * By default the macro just isn't replaced.
+ *
+ * @return string
+ */
+ public function resolve(array $variables, $default = null)
+ {
+ $macro = false;
+ $result = []; // kind of string builder
+
+ foreach ($this->template as $part) {
+ if ($macro) {
+ if (isset($variables[$part])) {
+ $result[] = $variables[$part];
+ } elseif ($part === '') {
+ $result[] = $this->macroCharacter;
+ } elseif ($default === null) {
+ $result[] = $this->macroCharacter;
+ $result[] = $part;
+ // add wildcards to result before they are
+ // overwritten from Template::getFullCurves()
+ if (isset($this->wildCards[$part])) {
+ $result[] = ':' . $this->wildCards[$part];
+ }
+
+ $result[] = $this->macroCharacter;
+ } else {
+ if (isset($this->wildCards[$part])) {
+ $result[] = $this->wildCards[$part];
+ } else {
+ $result[] = $default;
+ }
+ }
+ } else {
+ $result[] = $part;
+ }
+
+ $macro = ! $macro;
+ }
+
+ return implode($result);
+ }
+
+ /**
+ * Try to reverse-resolve the given string
+ *
+ * @param string $resolved A result of {@link resolve()}
+ *
+ * @return string[]|false Variables as passed to {@link resolve()} if successful
+ */
+ public function reverseResolve($resolved)
+ {
+ $matches = [];
+ if (! preg_match($this->getReverseResolvePattern(), $resolved, $matches)) {
+ return false;
+ }
+
+ $macro = false;
+ $macros = [];
+ $currentCapturedSubPatternIndex = 0;
+
+ foreach ($this->template as $part) {
+ if ($macro && ! isset($macros[$part])) {
+ $macros[$part] = ++$currentCapturedSubPatternIndex;
+ }
+
+ $macro = ! $macro;
+ }
+
+ $macros = array_flip($macros);
+
+ $result = [];
+ foreach ($matches as $index => $match) {
+ if ($index > 0) {
+ $result[$macros[$index]] = $match;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the raw template string this instance was constructed from
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->orgTemplate;
+ }
+
+ /**
+ * Return the macros of this template
+ *
+ * @return string[]
+ */
+ public function getMacros()
+ {
+ $macro = false;
+ $macros = [];
+
+ foreach ($this->template as $part) {
+ if ($macro) {
+ $macros[$part] = null;
+ }
+
+ $macro = ! $macro;
+ }
+
+ unset($macros['']);
+
+ return array_keys($macros);
+ }
+
+ /**
+ * Get macros' start and end character
+ *
+ * @return string
+ */
+ public function getMacroCharacter()
+ {
+ return $this->macroCharacter;
+ }
+
+ /**
+ * Get {@link reverseResolvePattern}
+ *
+ * @return string
+ */
+ protected function getReverseResolvePattern()
+ {
+ if ($this->reverseResolvePattern === null) {
+ $result = ['/\A']; // kind of string builder
+ $macro = false;
+ $macros = [];
+ $currentCapturedSubPatternIndex = 0;
+
+ foreach ($this->template as $part) {
+ if ($macro) {
+ if (isset($macros[$part])) {
+ $result[] = '\g{';
+ $result[] = $macros[$part];
+ $result[] = '}';
+ } else {
+ $macros[$part] = ++$currentCapturedSubPatternIndex;
+ $result[] = '(.*)';
+ }
+ } else {
+ $result[] = preg_quote($part, '/');
+ }
+
+ $macro = ! $macro;
+ }
+
+ $result[] = '\z/s';
+
+ $this->reverseResolvePattern = implode($result);
+ }
+
+ return $this->reverseResolvePattern;
+ }
+}
diff --git a/library/Graphite/Util/TimeRangePickerTools.php b/library/Graphite/Util/TimeRangePickerTools.php
new file mode 100644
index 0000000..d1ebc75
--- /dev/null
+++ b/library/Graphite/Util/TimeRangePickerTools.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Icinga\Module\Graphite\Util;
+
+use Icinga\Application\Config;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Web\Url;
+use Icinga\Web\UrlParams;
+
+final class TimeRangePickerTools
+{
+ /**
+ * @return string
+ */
+ public static function getRelativeRangeParameter()
+ {
+ return 'graph_range';
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAbsoluteRangeParameters()
+ {
+ return ['start' => 'graph_start', 'end' => 'graph_end'];
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAllRangeParameters()
+ {
+ return array_values(array_merge([static::getRelativeRangeParameter()], static::getAbsoluteRangeParameters()));
+ }
+
+ /**
+ * Copy {@link getAllRangeParameters()} from one {@link UrlParams} instance to another
+ *
+ * @param UrlParams|null $copy Defaults to a new instance
+ * @param UrlParams|null $origin Defaults to the current request's params
+ *
+ * @return UrlParams The copy
+ */
+ public static function copyAllRangeParameters(UrlParams $copy = null, UrlParams $origin = null)
+ {
+ if ($origin === null) {
+ $origin = Url::fromRequest()->getParams();
+ }
+ if ($copy === null) {
+ $copy = new UrlParams();
+ }
+
+ foreach (self::getAllRangeParameters() as $param) {
+ $value = $origin->get($param);
+ if ($value !== null) {
+ $copy->set($param, $value);
+ }
+ }
+
+ return $copy;
+ }
+
+ /**
+ * Extract the relative time range (if any) from the given URL parameters
+ *
+ * @param UrlParams $params
+ *
+ * @return bool|int|null
+ */
+ public static function getRelativeSeconds(UrlParams $params)
+ {
+ $seconds = $params->get(self::getRelativeRangeParameter());
+ if ($seconds === null) {
+ return null;
+ }
+
+ return preg_match('/^(?:0|[1-9]\d*)$/', $seconds) ? (int) $seconds : false;
+ }
+
+ /**
+ * Get the default relative time range for graphs
+ *
+ * @return int
+ *
+ * @throws ConfigurationError
+ */
+ public static function getDefaultRelativeTimeRange()
+ {
+ $rangeFactors = [
+ 'minutes' => 60,
+ 'hours' => 3600,
+ 'days' => 86400,
+ 'weeks' => 604800,
+ 'months' => 2592000,
+ 'years' => 31557600
+ ];
+
+ $config = Config::module('graphite');
+ $unit = $config->get('ui', 'default_time_range_unit', 'hours');
+
+ if (! isset($rangeFactors[$unit])) {
+ throw new ConfigurationError(
+ 'Bad ui.default_time_range_unit %s in file %s',
+ var_export($unit, true),
+ var_export($config->getConfigFile(), true)
+ );
+ }
+
+ return (int) $config->get('ui', 'default_time_range', 1) * $rangeFactors[$unit];
+ }
+}
diff --git a/library/Graphite/Web/Controller/IcingadbGraphiteController.php b/library/Graphite/Web/Controller/IcingadbGraphiteController.php
new file mode 100644
index 0000000..36bc026
--- /dev/null
+++ b/library/Graphite/Web/Controller/IcingadbGraphiteController.php
@@ -0,0 +1,110 @@
+<?php
+
+/* Icinga Graphite Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Graphite\Web\Controller;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Graphite\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\SearchControls;
+use ipl\Orm\Query;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\PaginationControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Filter\QueryString;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+
+class IcingadbGraphiteController extends CompatController
+{
+ use Auth;
+ use Database;
+ use SearchControls;
+
+ /** @var bool Whether to use icingadb as the backend */
+ protected $useIcingadbAsBackend;
+
+ /** @var string[] Graph parameters */
+ protected $graphParams = ['graphs_limit', 'graph_range', 'graph_start', 'graph_end', 'legacyParams'];
+
+ /** @var Filter\Rule Filter from query string parameters */
+ private $filter;
+
+ protected function moduleInit()
+ {
+ $this->useIcingadbAsBackend = Module::exists('icingadb') && IcingadbSupport::useIcingaDbAsBackend();
+ }
+
+ /**
+ * Get the filter created from query string parameters
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter(): Filter\Rule
+ {
+ if ($this->filter === null) {
+ $this->filter = QueryString::parse((string) $this->params);
+ }
+
+ return $this->filter;
+ }
+
+ /**
+ * Create and return the LimitControl
+ *
+ * This automatically shifts the limit URL parameter from {@link $params}.
+ *
+ * @return LimitControl
+ */
+ public function createLimitControl(): LimitControl
+ {
+ $limitControl = new LimitControl(Url::fromRequest());
+ $limitControl->setDefaultLimit($this->getPageSize(null));
+
+ $this->params->shift($limitControl->getLimitParam());
+
+ return $limitControl;
+ }
+
+ /**
+ * Create and return the PaginationControl
+ *
+ * This automatically shifts the pagination URL parameters from {@link $params}.
+ *
+ * @return PaginationControl
+ */
+ public function createPaginationControl(Paginatable $paginatable): PaginationControl
+ {
+ $paginationControl = new PaginationControl($paginatable, Url::fromRequest());
+ $paginationControl->setDefaultPageSize($this->getPageSize(null));
+ $paginationControl->setAttribute('id', $this->getRequest()->protectId('pagination-control'));
+
+ $this->params->shift($paginationControl->getPageParam());
+ $this->params->shift($paginationControl->getPageSizeParam());
+
+ return $paginationControl->apply();
+ }
+
+ /**
+ * Create and return the SortControl
+ *
+ * This automatically shifts the sort URL parameter from {@link $params}.
+ *
+ * @param Query $query
+ * @param array $columns Possible sort columns as sort string-label pairs
+ *
+ * @return SortControl
+ */
+ public function createSortControl(Query $query, array $columns): SortControl
+ {
+ $sortControl = SortControl::create($columns);
+
+ $this->params->shift($sortControl->getSortParam());
+
+ return $sortControl->apply($query);
+ }
+}
diff --git a/library/Graphite/Web/Controller/MonitoringAwareController.php b/library/Graphite/Web/Controller/MonitoringAwareController.php
new file mode 100644
index 0000000..dca2ebd
--- /dev/null
+++ b/library/Graphite/Web/Controller/MonitoringAwareController.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Controller;
+
+use ArrayIterator;
+use Icinga\Application\Modules\Module;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filterable;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\QueryException;
+use Icinga\Module\Graphite\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Module\Monitoring\Data\CustomvarProtectionIterator;
+use Icinga\Module\Monitoring\DataView\DataView;
+use Icinga\Util\Json;
+use Icinga\File\Csv;
+use Icinga\Web\Controller;
+use Icinga\Web\Url;
+
+abstract class MonitoringAwareController extends Controller
+{
+ /** @var bool Whether to use icingadb as the backend */
+ protected $useIcingadbAsBackend = false;
+
+ /**
+ * Restrict the given monitored object query for the currently authenticated user
+ *
+ * @param DataView $dataView
+ *
+ * @return DataView The given data view
+ */
+ protected function applyMonitoringRestriction(DataView $dataView)
+ {
+ $this->applyRestriction('monitoring/filter/objects', $dataView);
+
+ return $dataView;
+ }
+
+ protected function moduleInit()
+ {
+ if (Module::exists('icingadb') && IcingadbSupport::useIcingaDbAsBackend()) {
+ $this->useIcingadbAsBackend = true;
+
+ return;
+ }
+
+ $this->backend = MonitoringBackend::instance($this->_getParam('backend'));
+ $this->view->url = Url::fromRequest();
+ }
+
+
+ protected function handleFormatRequest($query)
+ {
+ $desiredContentType = $this->getRequest()->getHeader('Accept');
+ if ($desiredContentType === 'application/json') {
+ $desiredFormat = 'json';
+ } elseif ($desiredContentType === 'text/csv') {
+ $desiredFormat = 'csv';
+ } else {
+ $desiredFormat = strtolower($this->params->get('format', 'html'));
+ }
+
+ if ($desiredFormat !== 'html' && ! $this->params->has('limit')) {
+ $query->limit(); // Resets any default limit and offset
+ }
+
+ switch ($desiredFormat) {
+ case 'sql':
+ echo '<pre>'
+ . htmlspecialchars(wordwrap($query->dump()))
+ . '</pre>';
+ exit;
+ case 'json':
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'inline; filename=' . $this->getRequest()->getActionName() . '.json'
+ )
+ ->appendBody(
+ Json::sanitize(
+ iterator_to_array(
+ new CustomvarProtectionIterator(
+ new ArrayIterator($query->fetchAll())
+ )
+ )
+ )
+ )
+ ->sendResponse();
+ exit;
+ case 'csv':
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv'
+ )
+ ->appendBody((string) Csv::fromQuery(new CustomvarProtectionIterator($query)))
+ ->sendResponse();
+ exit;
+ }
+ }
+
+ /**
+ * Apply a restriction of the authenticated on the given filterable
+ *
+ * @param string $name Name of the restriction
+ * @param Filterable $filterable Filterable to restrict
+ *
+ * @return Filterable The filterable having the restriction applied
+ */
+ protected function applyRestriction($name, Filterable $filterable)
+ {
+ $filterable->applyFilter($this->getRestriction($name));
+ return $filterable;
+ }
+
+ /**
+ * Get a restriction of the authenticated
+ *
+ * @param string $name Name of the restriction
+ *
+ * @return Filter Filter object
+ * @throws ConfigurationError If the restriction contains invalid filter columns
+ */
+ protected function getRestriction($name)
+ {
+ $restriction = Filter::matchAny();
+ $restriction->setAllowedFilterColumns(array(
+ 'host_name',
+ 'hostgroup_name',
+ 'instance_name',
+ 'service_description',
+ 'servicegroup_name',
+ function ($c) {
+ return preg_match('/^_(?:host|service)_/i', $c);
+ }
+ ));
+ foreach ($this->getRestrictions($name) as $filter) {
+ if ($filter === '*') {
+ return Filter::matchAll();
+ }
+ try {
+ $restriction->addFilter(Filter::fromQueryString($filter));
+ } catch (QueryException $e) {
+ throw new ConfigurationError(
+ $this->translate(
+ 'Cannot apply restriction %s using the filter %s. You can only use the following columns: %s'
+ ),
+ $name,
+ $filter,
+ implode(', ', array(
+ 'instance_name',
+ 'host_name',
+ 'hostgroup_name',
+ 'service_description',
+ 'servicegroup_name',
+ '_(host|service)_<customvar-name>'
+ )),
+ $e
+ );
+ }
+ }
+
+ if ($restriction->isEmpty()) {
+ return Filter::matchAll();
+ }
+
+ return $restriction;
+ }
+}
diff --git a/library/Graphite/Web/Controller/TimeRangePickerTrait.php b/library/Graphite/Web/Controller/TimeRangePickerTrait.php
new file mode 100644
index 0000000..7352b1b
--- /dev/null
+++ b/library/Graphite/Web/Controller/TimeRangePickerTrait.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Controller;
+
+use Icinga\Module\Graphite\Forms\TimeRangePicker\CommonForm;
+use Icinga\Module\Graphite\Forms\TimeRangePicker\CustomForm;
+use Icinga\Web\Request;
+use Icinga\Web\Url;
+use Icinga\Web\View;
+
+trait TimeRangePickerTrait
+{
+ /**
+ * @var CommonForm
+ */
+ protected $timeRangePickerCommonForm;
+
+ /**
+ * @var CustomForm
+ */
+ protected $timeRangePickerCustomForm;
+
+ /**
+ * Process the given request using the forms
+ *
+ * @param Request $request The request to be processed
+ *
+ * @return Request The request supposed to be processed
+ */
+ protected function handleTimeRangePickerRequest(Request $request = null)
+ {
+ $this->getTimeRangePickerCommonForm()->handleRequest($request);
+ return $this->getTimeRangePickerCustomForm()->handleRequest($request);
+ }
+
+ /**
+ * Render all needed forms and links
+ *
+ * @param View $view
+ *
+ * @return string
+ */
+ protected function renderTimeRangePicker(View $view)
+ {
+ $url = Url::fromRequest()->getAbsoluteUrl();
+
+ return '<div class="timerangepicker-container">'
+ . $this->getTimeRangePickerCommonForm()
+ . '<div class="flyover flyover-arrow-top" data-flyover-suspends-auto-refresh id="'
+ . $view->protectId('graphite-customrange')
+ . '">'
+ . $view->qlink(null, '#', null, [
+ 'title' => $view->translate('Specify custom time range'),
+ 'class' => 'button-link flyover-toggle',
+ 'icon' => 'service'
+ ])
+ . '<div class="flyover-content">' . $this->getTimeRangePickerCustomForm() . '</div>'
+ . '</div>'
+ . '</div>';
+ }
+
+ /**
+ * Get {@link timeRangePickerCommonForm}
+ *
+ * @return CommonForm
+ */
+ public function getTimeRangePickerCommonForm()
+ {
+ if ($this->timeRangePickerCommonForm === null) {
+ $this->timeRangePickerCommonForm = new CommonForm();
+ }
+
+ return $this->timeRangePickerCommonForm;
+ }
+
+ /**
+ * Set {@link timeRangePickerCommonForm}
+ *
+ * @param CommonForm $timeRangePickerCommonForm
+ *
+ * @return $this
+ */
+ public function setTimeRangePickerCommonForm(CommonForm $timeRangePickerCommonForm)
+ {
+ $this->timeRangePickerCommonForm = $timeRangePickerCommonForm;
+ return $this;
+ }
+
+ /**
+ * Get {@link timeRangePickerCustomForm}
+ *
+ * @return CustomForm
+ */
+ public function getTimeRangePickerCustomForm()
+ {
+ if ($this->timeRangePickerCustomForm === null) {
+ $this->timeRangePickerCustomForm = new CustomForm();
+ }
+
+ return $this->timeRangePickerCustomForm;
+ }
+
+ /**
+ * Set {@link timeRangePickerCustomForm}
+ *
+ * @param CustomForm $timeRangePickerCustomForm
+ *
+ * @return $this
+ */
+ public function setTimeRangePickerCustomForm(CustomForm $timeRangePickerCustomForm)
+ {
+ $this->timeRangePickerCustomForm = $timeRangePickerCustomForm;
+ return $this;
+ }
+}
diff --git a/library/Graphite/Web/FakeSchemeRequest.php b/library/Graphite/Web/FakeSchemeRequest.php
new file mode 100644
index 0000000..dc415cd
--- /dev/null
+++ b/library/Graphite/Web/FakeSchemeRequest.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web;
+
+use Icinga\Web\Request;
+
+/**
+ * Rationale:
+ *
+ * {@link Url::fromPath()} doesn't preserve URLs which seem to be internal as they are.
+ */
+class FakeSchemeRequest extends Request
+{
+ public function getScheme()
+ {
+ return 'a random url scheme which always differs from the current request\'s one';
+ }
+}
diff --git a/library/Graphite/Web/Form/Decorator/Proxy.php b/library/Graphite/Web/Form/Decorator/Proxy.php
new file mode 100644
index 0000000..63d339c
--- /dev/null
+++ b/library/Graphite/Web/Form/Decorator/Proxy.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Form\Decorator;
+
+use Zend_Form_Decorator_Abstract;
+use Zend_Form_Decorator_Interface;
+
+/**
+ * Wrap a decorator and use it only for rendering
+ */
+class Proxy extends Zend_Form_Decorator_Abstract
+{
+ /**
+ * The actual decorator being proxied
+ *
+ * @var Zend_Form_Decorator_Interface
+ */
+ protected $actualDecorator;
+
+ public function render($content)
+ {
+ return $this->actualDecorator->render($content);
+ }
+
+ /**
+ * Get {@link actualDecorator}
+ *
+ * @return Zend_Form_Decorator_Interface
+ */
+ public function getActualDecorator()
+ {
+ return $this->actualDecorator;
+ }
+
+ /**
+ * Set {@link actualDecorator}
+ *
+ * @param Zend_Form_Decorator_Interface $actualDecorator
+ *
+ * @return $this
+ */
+ public function setActualDecorator($actualDecorator)
+ {
+ $this->actualDecorator = $actualDecorator;
+ return $this;
+ }
+}
diff --git a/library/Graphite/Web/Form/Validator/CustomErrorMessagesValidator.php b/library/Graphite/Web/Form/Validator/CustomErrorMessagesValidator.php
new file mode 100644
index 0000000..893a5b7
--- /dev/null
+++ b/library/Graphite/Web/Form/Validator/CustomErrorMessagesValidator.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Form\Validator;
+
+use Zend_Validate_Abstract;
+
+/**
+ * Provides an easy way to implement validators with custom error messages
+ *
+ * TODO(ak): move to framework(?)
+ */
+abstract class CustomErrorMessagesValidator extends Zend_Validate_Abstract
+{
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ $this->_messageTemplates = ['CUSTOM_ERROR' => ''];
+ }
+
+ public function isValid($value)
+ {
+ $errorMessage = $this->validate($value);
+ if ($errorMessage === null) {
+ return true;
+ }
+
+ $this->setMessage($errorMessage, 'CUSTOM_ERROR');
+ $this->_error('CUSTOM_ERROR');
+ return false;
+ }
+
+ /**
+ * Validate the given value and return an error message if it's invalid
+ *
+ * @param string $value
+ *
+ * @return string|null
+ */
+ abstract protected function validate($value);
+}
diff --git a/library/Graphite/Web/Form/Validator/HttpUserValidator.php b/library/Graphite/Web/Form/Validator/HttpUserValidator.php
new file mode 100644
index 0000000..d5f4a86
--- /dev/null
+++ b/library/Graphite/Web/Form/Validator/HttpUserValidator.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Form\Validator;
+
+use Zend_Validate_Abstract;
+
+/**
+ * Validates http basic authn user names
+ *
+ * TODO(ak): move to Icinga Web 2
+ */
+class HttpUserValidator extends Zend_Validate_Abstract
+{
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ $this->_messageTemplates = ['HAS_COLON' => mt('graphite', 'The username must not contain colons.')];
+ }
+
+ public function isValid($value)
+ {
+ $hasColon = false !== strpos($value, ':');
+ if ($hasColon) {
+ $this->_error('HAS_COLON');
+ }
+ return ! $hasColon;
+ }
+}
diff --git a/library/Graphite/Web/Form/Validator/MacroTemplateValidator.php b/library/Graphite/Web/Form/Validator/MacroTemplateValidator.php
new file mode 100644
index 0000000..8ff4e3c
--- /dev/null
+++ b/library/Graphite/Web/Form/Validator/MacroTemplateValidator.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Form\Validator;
+
+use Icinga\Module\Graphite\Util\MacroTemplate;
+use InvalidArgumentException;
+
+/**
+ * Validates Icinga-style macro templates
+ */
+class MacroTemplateValidator extends CustomErrorMessagesValidator
+{
+ protected function validate($value)
+ {
+ try {
+ new MacroTemplate($value);
+ } catch (InvalidArgumentException $e) {
+ return $e->getMessage();
+ }
+ }
+}
diff --git a/library/Graphite/Web/Widget/GraphImage.php b/library/Graphite/Web/Widget/GraphImage.php
new file mode 100644
index 0000000..af64e69
--- /dev/null
+++ b/library/Graphite/Web/Widget/GraphImage.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Widget;
+
+use Icinga\Module\Graphite\Graphing\Chart;
+use Icinga\Web\Url;
+use Icinga\Web\UrlParams;
+use Icinga\Web\Widget\AbstractWidget;
+use RuntimeException;
+
+class GraphImage extends AbstractWidget
+{
+ /**
+ * The chart to be rendered
+ *
+ * @var Chart
+ */
+ protected $chart;
+
+ /**
+ * The rendered PNG image
+ *
+ * @var string|null
+ */
+ protected $rendered;
+
+ /**
+ * Constructor
+ *
+ * @param Chart $chart The chart to be rendered
+ */
+ public function __construct(Chart $chart)
+ {
+ $this->chart = $chart;
+ }
+
+ /**
+ * Render the graph lazily
+ *
+ * @return string
+ */
+ public function render()
+ {
+ if ($this->rendered === null) {
+ $now = time();
+
+ $from = (int) $this->chart->getFrom();
+ if ($from < 0) {
+ $from += $now;
+ }
+
+ $until = (string) $this->chart->getUntil();
+
+ if ($until === '') {
+ $until = $now;
+ } else {
+ $until = (int) $until;
+ if ($until < 0) {
+ $until += $now;
+ }
+ }
+
+ $variables = $this->chart->getMetricVariables();
+ $template = $this->chart->getTemplate();
+ $graphiteWebClient = $this->chart->getGraphiteWebClient();
+ $params = (new UrlParams())->addValues([
+ 'from' => $from,
+ 'until' => $until,
+ 'bgcolor' => $this->chart->getBackgroundColor() ?? 'black',
+ 'fgcolor' => $this->chart->getForegroundColor() ?? 'white',
+ 'majorGridLineColor' => $this->chart->getMajorGridLineColor() ?? '0000003F',
+ 'minorGridLineColor' => $this->chart->getMinorGridLineColor() ?? 'black',
+ 'width' => $this->chart->getWidth(),
+ 'height' => $this->chart->getHeight(),
+ 'hideLegend' => (string) ! $this->chart->getShowLegend(),
+ 'tz' => date_default_timezone_get(),
+ '_salt' => "$now.000",
+ 'vTitle' => 'Percent',
+ 'lineMode' => 'connected',
+ 'drawNullAsZero' => 'false',
+ 'graphType' => 'line',
+ '_ext' => 'whatever.svg'
+ ]);
+
+ foreach ($template->getUrlParams() as $key => $value) {
+ $params->set($key, $value->resolve($variables));
+ }
+
+ $metrics = $this->chart->getMetrics();
+ $allVars = [];
+
+ foreach ($template->getCurves() as $curveName => $curve) {
+ if (!isset($metrics[$curveName])) {
+ continue;
+ }
+
+ $vars = $curve[0]->reverseResolve($metrics[$curveName]);
+
+ if ($vars !== false) {
+ $allVars = array_merge($allVars, $vars);
+ }
+ }
+
+ foreach ($metrics as $curveName => $metric) {
+ $allVars['metric'] = $metric;
+ $params->add('target', $template->getCurves()[$curveName][1]->resolve($allVars));
+ }
+
+ $url = Url::fromPath('/render')->setParams($params);
+ $headers = [
+ 'Accept-language' => 'en',
+ 'Content-type' => 'application/x-www-form-urlencoded'
+ ];
+
+ for (;;) {
+ try {
+ $this->rendered = $graphiteWebClient->request($url, 'GET', $headers);
+ } catch (RuntimeException $e) {
+ if (preg_match('/\b500\b/', $e->getMessage())) {
+ // A 500 Internal Server Error, probably because of
+ // a division by zero because of a too low time range to render.
+
+ $until = (int) $url->getParam('until');
+ $diff = $until - (int) $url->getParam('from');
+
+ // Try to render a higher time range, but give up
+ // once our default (1h) has been reached (non successfully).
+ if ($diff < 3600) {
+ $url->setParam('from', $until - $diff * 2);
+ continue;
+ }
+ }
+
+ throw $e;
+ }
+
+ break;
+ }
+ }
+
+ return $this->rendered;
+ }
+}
diff --git a/library/Graphite/Web/Widget/Graphs.php b/library/Graphite/Web/Widget/Graphs.php
new file mode 100644
index 0000000..e18b8da
--- /dev/null
+++ b/library/Graphite/Web/Widget/Graphs.php
@@ -0,0 +1,688 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Widget;
+
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Graphite\Graphing\Chart;
+use Icinga\Module\Graphite\Graphing\GraphingTrait;
+use Icinga\Module\Graphite\Graphing\Template;
+use Icinga\Module\Graphite\Util\InternalProcessTracker as IPT;
+use Icinga\Module\Graphite\Util\TimeRangePickerTools;
+use Icinga\Module\Graphite\Web\Widget\Graphs\Host as HostGraphs;
+use Icinga\Module\Graphite\Web\Widget\Graphs\Service as ServiceGraphs;
+use Icinga\Module\Monitoring\Object\Host;
+use Icinga\Module\Monitoring\Object\MonitoredObject;
+use Icinga\Module\Monitoring\Object\Service;
+use Icinga\Web\Request;
+use Icinga\Web\Url;
+use Icinga\Web\View;
+use Icinga\Web\Widget\AbstractWidget;
+use ipl\Orm\Model;
+use Icinga\Module\Icingadb\Model\Host as IcingadbHost;
+use Icinga\Module\Graphite\Web\Widget\Graphs\Icingadb\IcingadbHost as IcingadbHostGraphs;
+use Icinga\Module\Graphite\Web\Widget\Graphs\Icingadb\IcingadbService as IcingadbServiceGraphs;
+
+abstract class Graphs extends AbstractWidget
+{
+ use GraphingTrait;
+
+ /**
+ * The Icinga custom variable with the "real" check command (if any) of objects we display graphs for
+ *
+ * @var string
+ */
+ protected static $obscuredCheckCommandCustomVar;
+
+ /**
+ * The type of the object to render the graphs for
+ *
+ * @var string
+ */
+ protected $objectType;
+
+ /**
+ * The object to render the graphs for
+ *
+ * @var MonitoredObject|Model
+ */
+ protected $object;
+
+ /**
+ * Graph image width
+ *
+ * @var string
+ */
+ protected $width = '350';
+
+ /**
+ * Graph image height
+ *
+ * @var string
+ */
+ protected $height = '200';
+
+ /**
+ * Graph range start
+ *
+ * @var string
+ */
+ protected $start;
+
+ /**
+ * Graph range end
+ *
+ * @var string
+ */
+ protected $end;
+
+ /**
+ * Whether to render as compact as possible
+ *
+ * @var bool
+ */
+ protected $compact = false;
+
+ /**
+ * The check command of the monitored object we display graphs for
+ *
+ * @var string
+ */
+ protected $checkCommand;
+
+ /**
+ * The "real" check command (if any) of the monitored object we display graphs for
+ *
+ * E.g. the command executed remotely via check_by_ssh
+ *
+ * @var string|null
+ */
+ protected $obscuredCheckCommand;
+
+ /**
+ * Additional CSS classes for the <div/>s around the images
+ *
+ * @var string[]
+ */
+ protected $classes = [];
+
+ /**
+ * Whether to serve a transparent dummy image first and let the JS code load the actual graph
+ *
+ * @var bool
+ */
+ protected $preloadDummy = false;
+
+ /**
+ * Whether to render the graphs inline
+ *
+ * @var bool
+ */
+ protected $renderInline;
+
+ /**
+ * Whether to explicitly display that no graphs were found
+ *
+ * @var bool|null
+ */
+ protected $showNoGraphsFound;
+
+ /**
+ * Factory, based on the given monitoring object
+ *
+ * @param MonitoredObject $object
+ *
+ * @return static
+ */
+ public static function forMonitoredObject(MonitoredObject $object)
+ {
+ switch ($object->getType()) {
+ case 'host':
+ /** @var Host $object */
+ return new HostGraphs($object);
+
+ case 'service':
+ /** @var Service $object */
+ return new ServiceGraphs($object);
+ }
+ }
+
+ /**
+ * Factory, based on the given icingadb object
+ *
+ * @param Model $object
+ *
+ * @return static
+ */
+ public static function forIcingadbObject(Model $object)
+ {
+ if ($object instanceof IcingadbHost) {
+ return new IcingadbHostGraphs($object);
+ }
+
+ return new IcingadbServiceGraphs($object);
+ }
+
+ /**
+ * Get the Icinga custom variable with the "real" check command (if any) of monitored objects we display graphs for
+ *
+ * @return string
+ */
+ public static function getObscuredCheckCommandCustomVar()
+ {
+ if (static::$obscuredCheckCommandCustomVar === null) {
+ static::$obscuredCheckCommandCustomVar = Config::module('graphite')
+ ->get('icinga', 'customvar_obscured_check_command', 'check_command');
+ }
+
+ return static::$obscuredCheckCommandCustomVar;
+ }
+
+ /**
+ * Constructor
+ *
+ * @param MonitoredObject|Model $object The object to render the graphs for
+ */
+ public function __construct($object)
+ {
+ $this->object = $object;
+ $this->renderInline = Url::fromRequest()->getParam('format') === 'pdf';
+
+ if ($object instanceof Model) {
+ $this->checkCommand = $object->checkcommand_name;
+ $this->obscuredCheckCommand = $object->vars[Graphs::getObscuredCheckCommandCustomVar()] ?? null;
+ } else {
+ $this->checkCommand = $object->{"{$this->objectType}_check_command"};
+ $this->obscuredCheckCommand = $object->{
+ "_{$this->objectType}_" . Graphs::getObscuredCheckCommandCustomVar()
+ };
+ }
+ }
+
+ /**
+ * Process the given request using this widget
+ *
+ * @param Request $request The request to be processed
+ *
+ * @return $this
+ */
+ public function handleRequest(Request $request = null)
+ {
+ if ($request === null) {
+ $request = Icinga::app()->getRequest();
+ }
+
+ $params = $request->getUrl()->getParams();
+ list($this->start, $this->end) = $this->getRangeFromTimeRangePicker($request);
+ $this->width = $params->shift('width', $this->width);
+ $this->height = $params->shift('height', $this->height);
+
+ return $this;
+ }
+
+ /**
+ * Render the graphs list
+ *
+ * @return string
+ */
+ protected function getGraphsList()
+ {
+ $result = []; // kind of string builder
+ $imageBaseUrl = $this->getImageBaseUrl();
+ $allTemplates = $this->getAllTemplates();
+ $actualCheckCommand = $this->obscuredCheckCommand === null ? $this->checkCommand : $this->obscuredCheckCommand;
+ $concreteTemplates = $allTemplates->getTemplates($actualCheckCommand);
+
+ $excludedMetrics = [];
+
+ foreach ($concreteTemplates as $concreteTemplate) {
+ foreach ($concreteTemplate->getCurves() as $curve) {
+ $excludedMetrics[] = $curve[0];
+ }
+ }
+
+ IPT::recordf("Icinga check command: %s", $this->checkCommand);
+ IPT::recordf("Obscured check command: %s", $this->obscuredCheckCommand);
+
+ foreach (
+ [
+ ['template', $concreteTemplates, []],
+ ['default_template', $allTemplates->getDefaultTemplates(), $excludedMetrics],
+ ] as $templateSet
+ ) {
+ list($urlParam, $templates, $excludeMetrics) = $templateSet;
+
+ if ($urlParam === 'template') {
+ IPT::recordf('Applying templates for check command %s', $actualCheckCommand);
+ } else {
+ IPT::recordf('Applying default templates, excluding previously used metrics');
+ }
+
+ IPT::indent();
+
+ foreach ($templates as $templateName => $template) {
+ if ($this->designedForObjectType($template)) {
+ IPT::recordf('Applying template %s', $templateName);
+ IPT::indent();
+
+ $charts = $template->getCharts(
+ static::getMetricsDataSource(),
+ $this->object,
+ [],
+ $excludeMetrics
+ );
+
+ if (! empty($charts)) {
+ $currentGraphs = [];
+
+ foreach ($charts as $chart) {
+ /** @var Chart $chart */
+
+ $metricVariables = $chart->getMetricVariables();
+ $bestIntersect = -1;
+ $bestPos = count($result);
+
+ foreach ($result as $graphPos => & $graph) {
+ $currentIntersect = count(array_intersect_assoc($graph[1], $metricVariables));
+
+ if ($currentIntersect >= $bestIntersect) {
+ $bestIntersect = $currentIntersect;
+ $bestPos = $graphPos + 1;
+ }
+ }
+ unset($graph);
+
+ $urlParams = $template->getUrlParams();
+ if (array_key_exists("height", $urlParams)) {
+ $actheight = $urlParams["height"]->resolve(['height']);
+ if ($actheight < $this->height) {
+ $actheight = $this->height;
+ }
+ } else {
+ $actheight = $this->height;
+ }
+ $actwidth = $this->width;
+ $actwidthfix = "";
+ if (array_key_exists("width", $urlParams)) {
+ $actwidth = $urlParams["width"]->resolve(['width']);
+ $actwidthfix = "width: {$actwidth}px; ";
+ }
+
+ if ($this->renderInline) {
+ $chart->setFrom($this->start)
+ ->setUntil($this->end)
+ ->setWidth($actwidth)
+ ->setHeight($actheight)
+ ->setBackgroundColor('white')
+ ->setForegroundColor('black')
+ ->setMajorGridLineColor('grey')
+ ->setMinorGridLineColor('white')
+ ->setShowLegend(! $this->compact);
+
+ $img = new InlineGraphImage($chart);
+ } else {
+ $imageUrl = $this->filterImageUrl($imageBaseUrl->with($metricVariables))
+ ->setParam($urlParam, $templateName)
+ ->setParam('start', $this->start)
+ ->setParam('end', $this->end)
+ ->setParam('width', $actwidth)
+ ->setParam('height', $actheight);
+
+ if (! $this->compact) {
+ $imageUrl->setParam('legend', 1);
+ }
+
+ if ($this->preloadDummy) {
+ $src = 'data:image/png;base64,' // 1x1 dummy
+ . 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRS'
+ . 'TlMAQObYZgAAAApJREFUeAFjZAAAAAQAAhq+CAMAAAAASUVORK5CYII=';
+ } else {
+ $src = $imageUrl;
+ }
+
+ $img = '<img id="graphiteImg-' . md5((string) $imageUrl) . '"'
+ . " src=\"$src\" data-actualimageurl=\"$imageUrl\" class=\"detach graphiteImg\""
+ . " alt=\"\" width=\"$actwidth\" height=\"$actheight\""
+ . " style=\"min-width: {$actwidth}px; $actwidthfix min-height: {$actheight}px;\">";
+ }
+
+ $currentGraphs[] = [$img, $metricVariables, $bestPos];
+ }
+
+ foreach (array_reverse($currentGraphs) as $graph) {
+ list($img, $metricVariables, $bestPos) = $graph;
+ array_splice($result, $bestPos, 0, [[$img, $metricVariables]]);
+ }
+ }
+
+ IPT::unindent();
+ } else {
+ IPT::recordf('Not applying template %s', $templateName);
+ }
+ }
+
+ IPT::unindent();
+ }
+
+ if (! empty($result)) {
+ foreach ($result as & $graph) {
+ $graph = $graph[0];
+ }
+ unset($graph);
+
+ $currentUrl = Icinga::app()->getRequest()->getUrl();
+ $limit = (int) $currentUrl->getParam('graphs_limit', 2);
+ $total = count($result);
+
+ if ($limit < 1) {
+ $limit = -1;
+ }
+
+ if ($limit !== -1 && $total > $limit) {
+ $result = array_slice($result, 0, $limit);
+
+ if (! $this->compact) {
+ /** @var View $view */
+ $view = $this->view();
+
+ $url = $this->getGraphsListBaseUrl();
+ TimeRangePickerTools::copyAllRangeParameters($url->getParams(), $currentUrl->getParams());
+
+ $result[] = "<p class='load-more'>{$view->qlink(
+ sprintf($view->translate('Load all %d graphs'), $total),
+ $url->setParam('graphs_limit', '-1'),
+ null,
+ ['class' => 'action-link']
+ )}</p>";
+ }
+ }
+
+ $classes = $this->classes;
+ $classes[] = 'images';
+
+ array_unshift($result, '<div class="' . implode(' ', $classes) . '">');
+ $result[] = '</div>';
+ }
+
+ if ($this->renderInline) {
+ foreach ($result as $html) {
+ if ($html instanceof InlineGraphImage) {
+ // Errors should occur now or not at all
+ $html->render();
+ }
+ }
+ }
+
+ return implode($result);
+ }
+
+ public function render()
+ {
+ IPT::clear();
+
+ try {
+ $result = $this->getGraphsList();
+ } catch (ConfigurationError $e) {
+ $view = $this->view();
+
+ return "<p>{$view->escape($e->getMessage())}</p>"
+ . '<p>' . vsprintf(
+ $view->escape($view->translate('Please %scorrect%s the configuration of the Graphite module.')),
+ Auth::getInstance()->hasPermission('config/modules')
+ ? explode(
+ '$LINK_TEXT$',
+ $view->qlink('$LINK_TEXT$', 'graphite/config/backend', null, ['class' => 'action-link'])
+ )
+ : ['', '']
+ ) . '</p>';
+ }
+
+ $view = $this->view();
+
+ if ($result === '' && $this->getShowNoGraphsFound()) {
+ $result = "<p>{$view->escape($view->translate('No graphs found'))}</p>";
+ }
+
+ if (IPT::enabled()) {
+ $result .= "<h3>{$view->escape($view->translate('Graphs assembling process record'))}</h3>"
+ . "<pre>{$view->escape(IPT::dump())}</pre>";
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get time range parameters for Graphite from the URL
+ *
+ * @param Request $request The request to be used
+ *
+ * @return string[]
+ */
+ protected function getRangeFromTimeRangePicker(Request $request)
+ {
+ $params = $request->getUrl()->getParams();
+ $relative = $params->get(TimeRangePickerTools::getRelativeRangeParameter());
+ if ($relative !== null) {
+ return ["-$relative", null];
+ }
+
+ $absolute = TimeRangePickerTools::getAbsoluteRangeParameters();
+ $start = $params->get($absolute['start']);
+ return [
+ $start === null ? -TimeRangePickerTools::getDefaultRelativeTimeRange() : $start,
+ $params->get($absolute['end'])
+ ];
+ }
+
+ /**
+ * Return a identifier specifying the monitored object we display graphs for
+ *
+ * @return string
+ */
+ abstract protected function getMonitoredObjectIdentifier();
+
+ /**
+ * Get the base URL to a graph specifying just the monitored object kind
+ *
+ * @return Url
+ */
+ abstract protected function getImageBaseUrl();
+
+ /**
+ * Get the base URL to the monitored object's graphs list
+ *
+ * @return Url
+ */
+ abstract protected function getGraphsListBaseUrl();
+
+ /**
+ * Extend the {@link getImageBaseUrl()}'s result's parameters with the concrete monitored object
+ *
+ * @param Url $url The URL to extend
+ *
+ * @return Url The given URL
+ */
+ abstract protected function filterImageUrl(Url $url);
+
+ /**
+ * Return whether the given template is designed for the type of the object we display graphs for
+ *
+ * @param Template $template
+ *
+ * @return bool
+ */
+ abstract protected function designedForObjectType(Template $template);
+
+ /**
+ * Get {@link compact}
+ *
+ * @return bool
+ */
+ public function getCompact()
+ {
+ return $this->compact;
+ }
+
+ /**
+ * Set {@link compact}
+ *
+ * @param bool $compact
+ *
+ * @return $this
+ */
+ public function setCompact($compact = true)
+ {
+ $this->compact = $compact;
+ return $this;
+ }
+
+ /**
+ * Get the graph image width
+ *
+ * @return string
+ */
+ public function getWidth()
+ {
+ return $this->width;
+ }
+
+ /**
+ * Set the graph image width
+ *
+ * @param string $width
+ *
+ * @return $this
+ */
+ public function setWidth($width)
+ {
+ $this->width = $width;
+
+ return $this;
+ }
+
+ /**
+ * Get the graph image height
+ *
+ * @return string
+ */
+ public function getHeight()
+ {
+ return $this->height;
+ }
+
+ /**
+ * Set the graph image height
+ *
+ * @param string $height
+ *
+ * @return $this
+ */
+ public function setHeight($height)
+ {
+ $this->height = $height;
+
+ return $this;
+ }
+
+ /**
+ * Get additional CSS classes for the <div/>s around the images
+ *
+ * @return string[]
+ */
+ public function getClasses()
+ {
+ return $this->classes;
+ }
+
+ /**
+ * Set additional CSS classes for the <div/>s around the images
+ *
+ * @param string[] $classes
+ *
+ * @return $this
+ */
+ public function setClasses($classes)
+ {
+ $this->classes = $classes;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to serve a transparent dummy image first and let the JS code load the actual graph
+ *
+ * @return bool
+ */
+ public function getPreloadDummy()
+ {
+ return $this->preloadDummy;
+ }
+
+ /**
+ * Set whether to serve a transparent dummy image first and let the JS code load the actual graph
+ *
+ * @param bool $preloadDummy
+ *
+ * @return $this
+ */
+ public function setPreloadDummy($preloadDummy = true)
+ {
+ $this->preloadDummy = $preloadDummy;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to render the graphs inline
+ *
+ * @return bool
+ */
+ public function getRenderInline()
+ {
+ return $this->renderInline;
+ }
+
+ /**
+ * Set whether to render the graphs inline
+ *
+ * @param bool $renderInline
+ *
+ * @return $this
+ */
+ public function setRenderInline($renderInline = true)
+ {
+ $this->renderInline = $renderInline;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to explicitly display that no graphs were found
+ *
+ * @return bool
+ */
+ public function getShowNoGraphsFound()
+ {
+ if ($this->showNoGraphsFound === null) {
+ $this->showNoGraphsFound = ! Config::module('graphite')->get('ui', 'disable_no_graphs_found');
+ }
+
+ return $this->showNoGraphsFound;
+ }
+
+ /**
+ * Set whether to explicitly display that no graphs were found
+ *
+ * @param bool $showNoGraphsFound
+ *
+ * @return $this
+ */
+ public function setShowNoGraphsFound($showNoGraphsFound = true)
+ {
+ $this->showNoGraphsFound = $showNoGraphsFound;
+
+ return $this;
+ }
+}
diff --git a/library/Graphite/Web/Widget/Graphs/Host.php b/library/Graphite/Web/Widget/Graphs/Host.php
new file mode 100644
index 0000000..2247bcc
--- /dev/null
+++ b/library/Graphite/Web/Widget/Graphs/Host.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Widget\Graphs;
+
+use Icinga\Module\Graphite\Graphing\Template;
+use Icinga\Module\Graphite\Web\Widget\Graphs;
+use Icinga\Module\Monitoring\Object\Host as MonitoredHost;
+use Icinga\Web\Url;
+
+class Host extends Graphs
+{
+ protected $objectType = 'host';
+
+ /**
+ * The host to render the graphs of
+ *
+ * @var MonitoredHost
+ */
+ protected $object;
+
+ protected function getImageBaseUrl()
+ {
+ return Url::fromPath('graphite/graph/host');
+ }
+
+ protected function getGraphsListBaseUrl()
+ {
+ return Url::fromPath('graphite/list/hosts', ['host' => $this->object->getName()]);
+ }
+
+ protected function filterImageUrl(Url $url)
+ {
+ return $url->setParam('host.name', $this->object->getName());
+ }
+
+ protected function getMonitoredObjectIdentifier()
+ {
+ return $this->object->getName();
+ }
+
+ protected function designedForObjectType(Template $template)
+ {
+ foreach ($template->getCurves() as $curve) {
+ if (in_array('host_name_template', $curve[0]->getMacros())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbHost.php b/library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbHost.php
new file mode 100644
index 0000000..2b0a614
--- /dev/null
+++ b/library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbHost.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Widget\Graphs\Icingadb;
+
+use Icinga\Module\Graphite\Graphing\Template;
+use Icinga\Module\Graphite\Web\Widget\Graphs;
+use Icinga\Web\Url;
+use Icinga\Module\Icingadb\Model\Host;
+
+class IcingadbHost extends Graphs
+{
+ protected $objectType = 'host';
+
+ /**
+ * The Icingadb host to render the graphs for
+ *
+ * @var Host
+ */
+ protected $object;
+
+ protected function getGraphsListBaseUrl()
+ {
+ return Url::fromPath('graphite/hosts', ['host.name' => $this->object->name]);
+ }
+
+ protected function filterImageUrl(Url $url)
+ {
+ return $url->setParam('host.name', $this->object->name);
+ }
+
+ public function createHostTitle()
+ {
+ return $this->object->name;
+ }
+
+ public function getObjectType()
+ {
+ return $this->objectType;
+ }
+
+ protected function getMonitoredObjectIdentifier()
+ {
+ return $this->object->name;
+ }
+
+ protected function getImageBaseUrl()
+ {
+ return Url::fromPath('graphite/graph/host');
+ }
+
+ protected function designedForObjectType(Template $template)
+ {
+ foreach ($template->getCurves() as $curve) {
+ if (in_array('host_name_template', $curve[0]->getMacros())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbService.php b/library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbService.php
new file mode 100644
index 0000000..7827e86
--- /dev/null
+++ b/library/Graphite/Web/Widget/Graphs/Icingadb/IcingadbService.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Widget\Graphs\Icingadb;
+
+use Icinga\Module\Graphite\Graphing\Template;
+use Icinga\Module\Graphite\Web\Widget\Graphs;
+use Icinga\Web\Url;
+use Icinga\Module\Icingadb\Model\Service;
+
+class IcingadbService extends Graphs
+{
+ protected $objectType = 'service';
+
+ /**
+ * The icingadb service to render the graphs for
+ *
+ * @var Service
+ */
+ protected $object;
+
+ protected function getGraphsListBaseUrl()
+ {
+ return Url::fromPath(
+ 'graphite/services',
+ ['service.name' => $this->object->name, 'host.name' => $this->object->host->name]
+ );
+ }
+
+ protected function filterImageUrl(Url $url)
+ {
+ return $url
+ ->setParam('host.name', $this->object->host->name)
+ ->setParam('service.name', $this->object->name);
+ }
+
+ public function createHostTitle()
+ {
+ return $this->object->host->name;
+ }
+
+ public function createServiceTitle()
+ {
+ return ' : ' . $this->object->name;
+ }
+
+ public function getObjectType()
+ {
+ return $this->objectType;
+ }
+
+ protected function getMonitoredObjectIdentifier()
+ {
+ return $this->object->host->name . ':' . $this->object->name;
+ }
+
+ protected function getImageBaseUrl()
+ {
+ return Url::fromPath('graphite/graph/service');
+ }
+
+ protected function designedforObjectType(Template $template)
+ {
+ foreach ($template->getCurves() as $curve) {
+ if (in_array('service_name_template', $curve[0]->getMacros())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/library/Graphite/Web/Widget/Graphs/Service.php b/library/Graphite/Web/Widget/Graphs/Service.php
new file mode 100644
index 0000000..5fc0143
--- /dev/null
+++ b/library/Graphite/Web/Widget/Graphs/Service.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Widget\Graphs;
+
+use Icinga\Module\Graphite\Graphing\Template;
+use Icinga\Module\Graphite\Web\Widget\Graphs;
+use Icinga\Module\Monitoring\Object\Service as MonitoredService;
+use Icinga\Web\Url;
+
+class Service extends Graphs
+{
+ protected $objectType = 'service';
+
+ /**
+ * The service to render the graphs for
+ *
+ * @var MonitoredService
+ */
+ protected $object;
+
+ protected function getImageBaseUrl()
+ {
+ return Url::fromPath('graphite/graph/service');
+ }
+
+ protected function getGraphsListBaseUrl()
+ {
+ return Url::fromPath(
+ 'graphite/list/services',
+ ['host' => $this->object->getHost()->getName(), 'service' => $this->object->getName()]
+ );
+ }
+
+ protected function filterImageUrl(Url $url)
+ {
+ return $url
+ ->setParam('host.name', $this->object->getHost()->getName())
+ ->setParam('service.name', $this->object->getName());
+ }
+
+ protected function getMonitoredObjectIdentifier()
+ {
+ return $this->object->getHost()->getName() . ':' . $this->object->getName();
+ }
+
+ protected function designedForObjectType(Template $template)
+ {
+ foreach ($template->getCurves() as $curve) {
+ if (in_array('service_name_template', $curve[0]->getMacros())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/library/Graphite/Web/Widget/IcingadbGraphs.php b/library/Graphite/Web/Widget/IcingadbGraphs.php
new file mode 100644
index 0000000..e038e92
--- /dev/null
+++ b/library/Graphite/Web/Widget/IcingadbGraphs.php
@@ -0,0 +1,106 @@
+<?php
+
+/* Icinga Graphite Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Graphite\Web\Widget;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Graphite\Web\Widget\Graphs\Icingadb\IcingadbHost;
+use Icinga\Module\Graphite\Web\Widget\Graphs\Icingadb\IcingadbService;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\BaseFilter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Link;
+
+/**
+* Class for creating graphs of icingadb objects
+*/
+class IcingadbGraphs extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $defaultAttributes = ['class' => 'grid'];
+
+ /** @var Iterable */
+ protected $objects;
+
+ protected $tag = 'div';
+
+ /**
+ * Create a new Graph item
+ *
+ * @param ResultSet $objects
+ */
+ public function __construct(ResultSet $objects)
+ {
+ $this->objects = $objects;
+ }
+
+ protected function assemble()
+ {
+ if (! $this->objects->hasResult()) {
+ $this->add(new EmptyState(t('No items found.')));
+ }
+
+ foreach ($this->objects as $object) {
+ $this->add($this->createGridItem($object));
+ }
+
+ $document = new HtmlDocument();
+ $document->addHtml(Html::tag('div', ['class' => 'graphite-graph-color-registry']), $this);
+ $this->prependWrapper($document);
+ }
+
+ protected function createGridItem($object)
+ {
+ if ($object instanceof Host) {
+ $graph = new IcingadbHost($object);
+ $hostObj = $object;
+ } else {
+ $graph = new IcingadbService($object);
+ $hostObj = $object->host;
+ }
+
+ $hostUrl = Links::host($hostObj);
+
+ if ($this->hasBaseFilter()) {
+ $hostUrl->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ $hostLink = new Link(
+ $graph->createHostTitle(),
+ $hostUrl,
+ ['data-base-target' => '_next']
+ );
+
+ $serviceLink = null;
+ if ($graph->getObjectType() === 'service') {
+ $serviceUrl = Links::service($object, $hostObj);
+
+ if ($this->hasBaseFilter()) {
+ $serviceUrl->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ $serviceLink = new Link(
+ $graph->createServiceTitle(),
+ $serviceUrl,
+ ['data-base-target' => '_next']
+ );
+ }
+
+ $gridItem = Html::tag('div', ['class' => 'grid-item']);
+ $header = Html::tag('h2');
+
+ $header->add([$hostLink, $serviceLink]);
+ $gridItem->add($header);
+
+ return $gridItem->add(HtmlString::create($graph->setPreloadDummy()->handleRequest()));
+ }
+}
diff --git a/library/Graphite/Web/Widget/InlineGraphImage.php b/library/Graphite/Web/Widget/InlineGraphImage.php
new file mode 100644
index 0000000..881384d
--- /dev/null
+++ b/library/Graphite/Web/Widget/InlineGraphImage.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Icinga\Module\Graphite\Web\Widget;
+
+use Icinga\Module\Graphite\Graphing\Chart;
+use Icinga\Web\Widget\AbstractWidget;
+
+class InlineGraphImage extends AbstractWidget
+{
+ /**
+ * The image to be rendered
+ *
+ * @var GraphImage
+ */
+ protected $image;
+
+ /**
+ * The rendered <img>
+ *
+ * @var string|null
+ */
+ protected $rendered;
+
+ /**
+ * Constructor
+ *
+ * @param Chart $chart The chart to be rendered
+ */
+ public function __construct(Chart $chart)
+ {
+ $this->image = new GraphImage($chart);
+ }
+
+ /**
+ * Render the graph lazily
+ *
+ * @return string
+ */
+ public function render()
+ {
+ if ($this->rendered === null) {
+ $this->rendered = '<img src="data:image/png;base64,'
+ . implode("\n", str_split(base64_encode($this->image->render()), 76))
+ . '">';
+ }
+
+ return $this->rendered;
+ }
+}