diff options
Diffstat (limited to 'library/Graphite')
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; + } +} |