diff options
Diffstat (limited to 'library/Icingadb/Util')
-rw-r--r-- | library/Icingadb/Util/FeatureStatus.php | 50 | ||||
-rw-r--r-- | library/Icingadb/Util/ObjectSuggestionsCursor.php | 25 | ||||
-rw-r--r-- | library/Icingadb/Util/PerfData.php | 642 | ||||
-rw-r--r-- | library/Icingadb/Util/PerfDataFormat.php | 172 | ||||
-rw-r--r-- | library/Icingadb/Util/PerfDataSet.php | 159 | ||||
-rw-r--r-- | library/Icingadb/Util/PluginOutput.php | 260 | ||||
-rw-r--r-- | library/Icingadb/Util/ThresholdRange.php | 180 |
7 files changed, 1488 insertions, 0 deletions
diff --git a/library/Icingadb/Util/FeatureStatus.php b/library/Icingadb/Util/FeatureStatus.php new file mode 100644 index 0000000..94bf6d4 --- /dev/null +++ b/library/Icingadb/Util/FeatureStatus.php @@ -0,0 +1,50 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use ArrayObject; +use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand; + +class FeatureStatus extends ArrayObject +{ + public function __construct(string $type, $summary) + { + $prefix = "{$type}s"; + + $featureStatus = [ + ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS => + $this->getFeatureStatus('active_checks_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS => + $this->getFeatureStatus('passive_checks_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS => + $this->getFeatureStatus('notifications_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER => + $this->getFeatureStatus('event_handler_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION => + $this->getFeatureStatus('flapping_enabled', $prefix, $summary) + ]; + + parent::__construct($featureStatus, ArrayObject::ARRAY_AS_PROPS); + } + + protected function getFeatureStatus(string $feature, string $prefix, $summary): int + { + $key = "{$prefix}_{$feature}"; + $value = (int) $summary->$key; + + if ($value === 0) { + return 0; + } + + $totalKey = "{$prefix}_total"; + $total = (int) $summary->$totalKey; + + if ($value === $total) { + return 1; + } + + return 2; + } +} diff --git a/library/Icingadb/Util/ObjectSuggestionsCursor.php b/library/Icingadb/Util/ObjectSuggestionsCursor.php new file mode 100644 index 0000000..0013b35 --- /dev/null +++ b/library/Icingadb/Util/ObjectSuggestionsCursor.php @@ -0,0 +1,25 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use ipl\Sql\Cursor; +use Iterator; + +class ObjectSuggestionsCursor extends Cursor +{ + public function getIterator(): \Traversable + { + foreach (parent::getIterator() as $key => $value) { + // TODO(lippserd): This is a quick and dirty fix for PostgreSQL binary datatypes for which PDO returns + // PHP resources that would cause exceptions since resources are not a valid type for attribute values. + // We need to do it this way as the suggestion implementation bypasses ORM behaviors here and there. + if (is_resource($value)) { + $value = stream_get_contents($value); + } + + yield $key => $value; + } + } +} diff --git a/library/Icingadb/Util/PerfData.php b/library/Icingadb/Util/PerfData.php new file mode 100644 index 0000000..2d83c54 --- /dev/null +++ b/library/Icingadb/Util/PerfData.php @@ -0,0 +1,642 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Web\Widget\Chart\InlinePie; +use InvalidArgumentException; +use LogicException; + +class PerfData +{ + const PERFDATA_OK = 'ok'; + const PERFDATA_WARNING = 'warning'; + const PERFDATA_CRITICAL = 'critical'; + + /** + * The performance data value being parsed + * + * @var string + */ + protected $perfdataValue; + + /** + * Unit of measurement (UOM) + * + * @var string + */ + protected $unit; + + /** + * The label + * + * @var string + */ + protected $label; + + /** + * The value + * + * @var float + */ + protected $value; + + /** + * The minimum value + * + * @var float + */ + protected $minValue; + + /** + * The maximum value + * + * @var float + */ + protected $maxValue; + + /** + * The WARNING threshold + * + * @var ThresholdRange + */ + protected $warningThreshold; + + /** + * The CRITICAL threshold + * + * @var ThresholdRange + */ + protected $criticalThreshold; + + /** + * Create a new PerfData object based on the given performance data label and value + * + * @param string $label The perfdata label + * @param string $value The perfdata value + */ + public function __construct(string $label, string $value) + { + $this->perfdataValue = $value; + $this->label = $label; + $this->parse(); + + if ($this->unit === '%') { + if ($this->minValue === null) { + $this->minValue = 0.0; + } + if ($this->maxValue === null) { + $this->maxValue = 100.0; + } + } + + $warn = $this->warningThreshold->getMax(); + if ($warn !== null) { + $crit = $this->criticalThreshold->getMax(); + if ($crit !== null && $warn > $crit) { + $this->warningThreshold->setInverted(); + $this->criticalThreshold->setInverted(); + } + } + } + + /** + * Return a new PerfData object based on the given performance data key=value pair + * + * @param string $perfdata The key=value pair to parse + * + * @return PerfData + * + * @throws InvalidArgumentException In case the given performance data has no content or a invalid format + */ + public static function fromString(string $perfdata): self + { + if (empty($perfdata)) { + throw new InvalidArgumentException('PerfData::fromString expects a string with content'); + } elseif (strpos($perfdata, '=') === false) { + throw new InvalidArgumentException( + 'PerfData::fromString expects a key=value formatted string. Got "' . $perfdata . '" instead' + ); + } + + list($label, $value) = explode('=', $perfdata, 2); + return new static(trim($label), trim($value)); + } + + /** + * Return whether this performance data's value is a number + * + * @return bool True in case it's a number, otherwise False + */ + public function isNumber(): bool + { + return $this->unit === null; + } + + /** + * Return whether this performance data's value are seconds + * + * @return bool True in case it's seconds, otherwise False + */ + public function isSeconds(): bool + { + return $this->unit === 's'; + } + + /** + * Return whether this performance data's value is a temperature + * + * @return bool True in case it's temperature, otherwise False + */ + public function isTemperature(): bool + { + return in_array($this->unit, array('C', 'F', 'K')); + } + + /** + * Return whether this performance data's value is in percentage + * + * @return bool True in case it's in percentage, otherwise False + */ + public function isPercentage(): bool + { + return $this->unit === '%'; + } + + /** + * Get whether this perf data's value is in packets + * + * @return bool True in case it's in packets + */ + public function isPackets(): bool + { + return $this->unit === 'packets'; + } + + /** + * Get whether this perf data's value is in lumen + * + * @return bool + */ + public function isLumens(): bool + { + return $this->unit === 'lm'; + } + + /** + * Get whether this perf data's value is in decibel-milliwatts + * + * @return bool + */ + public function isDecibelMilliWatts(): bool + { + return $this->unit === 'dBm'; + } + + /** + * Get whether this data's value is in bits + * + * @return bool + */ + public function isBits(): bool + { + return $this->unit === 'b'; + } + + /** + * Return whether this performance data's value is in bytes + * + * @return bool True in case it's in bytes, otherwise False + */ + public function isBytes(): bool + { + return $this->unit === 'B'; + } + + /** + * Get whether this data's value is in watt hours + * + * @return bool + */ + public function isWattHours(): bool + { + return $this->unit === 'Wh'; + } + + /** + * Get whether this data's value is in watt + * + * @return bool + */ + public function isWatts(): bool + { + return $this->unit === 'W'; + } + + /** + * Get whether this data's value is in ampere + * + * @return bool + */ + public function isAmperes(): bool + { + return $this->unit === 'A'; + } + + /** + * Get whether this data's value is in ampere seconds + * + * @return bool + */ + public function isAmpSeconds(): bool + { + return $this->unit === 'As'; + } + + /** + * Get whether this data's value is in volts + * + * @return bool + */ + public function isVolts(): bool + { + return $this->unit === 'V'; + } + + /** + * Get whether this data's value is in ohm + * + * @return bool + */ + public function isOhms(): bool + { + return $this->unit === 'O'; + } + + /** + * Get whether this data's value is in grams + * + * @return bool + */ + public function isGrams(): bool + { + return $this->unit === 'g'; + } + + /** + * Get whether this data's value is in Litters + * + * @return bool + */ + public function isLiters(): bool + { + return $this->unit === 'l'; + } + + /** + * Return whether this performance data's value is a counter + * + * @return bool True in case it's a counter, otherwise False + */ + public function isCounter(): bool + { + return $this->unit === 'c'; + } + + /** + * Returns whether it is possible to display a visual representation + * + * @return bool True when the perfdata is visualizable + */ + public function isVisualizable(): bool + { + return isset($this->minValue) && isset($this->maxValue) && isset($this->value); + } + + /** + * Return this perfomance data's label + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * Return the value or null if it is unknown (U) + * + * @return null|float + */ + public function getValue() + { + return $this->value; + } + + /** + * Return the unit as a string + * + * @return ?string + */ + public function getUnit() + { + return $this->unit; + } + + /** + * Return the value as percentage (0-100) + * + * @return null|float + */ + public function getPercentage() + { + if ($this->isPercentage()) { + return $this->value; + } + + if ($this->maxValue !== null) { + $minValue = $this->minValue !== null ? $this->minValue : 0.0; + if ($this->maxValue == $minValue) { + return null; + } + + if ($this->value > $minValue) { + return (($this->value - $minValue) / ($this->maxValue - $minValue)) * 100; + } + } + } + + /** + * Return this performance data's warning treshold + * + * @return ThresholdRange + */ + public function getWarningThreshold(): ThresholdRange + { + return $this->warningThreshold; + } + + /** + * Return this performance data's critical treshold + * + * @return ThresholdRange + */ + public function getCriticalThreshold(): ThresholdRange + { + return $this->criticalThreshold; + } + + /** + * Return the minimum value or null if it is not available + * + * @return ?float + */ + public function getMinimumValue() + { + return $this->minValue; + } + + /** + * Return the maximum value or null if it is not available + * + * @return null|float + */ + public function getMaximumValue() + { + return $this->maxValue; + } + + /** + * Return this performance data as string + * + * @return string + */ + public function __toString() + { + return $this->formatLabel(); + } + + /** + * Parse the current performance data value + * + * @todo Handle optional min/max if UOM == % + */ + protected function parse() + { + $parts = explode(';', $this->perfdataValue); + + $matches = array(); + if (preg_match('@^(-?(?:\d+)?(?:\.\d+)?)([a-zA-Z%°]{1,3})$@u', $parts[0], $matches)) { + $this->unit = $matches[2]; + $this->value = $matches[1]; + } else { + $this->value = $parts[0]; + } + + switch (count($parts)) { + /* @noinspection PhpMissingBreakStatementInspection */ + case 5: + if ($parts[4] !== '') { + $this->maxValue = $parts[4]; + } + /* @noinspection PhpMissingBreakStatementInspection */ + case 4: + if ($parts[3] !== '') { + $this->minValue = $parts[3]; + } + /* @noinspection PhpMissingBreakStatementInspection */ + case 3: + $this->criticalThreshold = ThresholdRange::fromString(trim($parts[2])); + // Fallthrough + case 2: + $this->warningThreshold = ThresholdRange::fromString(trim($parts[1])); + } + + if ($this->warningThreshold === null) { + $this->warningThreshold = new ThresholdRange(); + } + if ($this->criticalThreshold === null) { + $this->criticalThreshold = new ThresholdRange(); + } + } + + protected function calculatePieChartData(): array + { + $rawValue = $this->getValue(); + $minValue = $this->getMinimumValue() !== null ? $this->getMinimumValue() : 0; + $usedValue = ($rawValue - $minValue); + + $green = $orange = $red = 0; + + if ($this->criticalThreshold->contains($rawValue)) { + if ($this->warningThreshold->contains($rawValue)) { + $green = $usedValue; + } else { + $orange = $usedValue; + } + } else { + $red = $usedValue; + } + + return array($green, $orange, $red, ($this->getMaximumValue() - $minValue) - $usedValue); + } + + + public function asInlinePie(): InlinePie + { + if (! $this->isVisualizable()) { + throw new LogicException('Cannot calculate piechart data for unvisualizable perfdata entry.'); + } + + $data = $this->calculatePieChartData(); + $pieChart = new InlinePie($data, $this); + $pieChart->setColors(array('#44bb77', '#ffaa44', '#ff5566', '#ddccdd')); + + return $pieChart; + } + + /** + * Format the given value depending on the currently used unit + */ + protected function format($value) + { + if ($value === null) { + return null; + } + + if ($value instanceof ThresholdRange) { + if ($value->getMin()) { + return (string) $value; + } + + $max = $value->getMax(); + return $max === null ? '' : $this->format($max); + } + + switch (true) { + case $this->isPercentage(): + return (string) $value . '%'; + case $this->isPackets(): + return (string) $value . 'packets'; + case $this->isLumens(): + return (string) $value . 'lm'; + case $this->isDecibelMilliWatts(): + return (string) $value . 'dBm'; + case $this->isCounter(): + return (string) $value . 'c'; + case $this->isTemperature(): + return (string) $value . $this->unit; + case $this->isBits(): + return PerfDataFormat::bits($value); + case $this->isBytes(): + return PerfDataFormat::bytes($value); + case $this->isSeconds(): + return PerfDataFormat::seconds($value); + case $this->isWatts(): + return PerfDataFormat::watts($value); + case $this->isWattHours(): + return PerfDataFormat::wattHours($value); + case $this->isAmperes(): + return PerfDataFormat::amperes($value); + case $this->isAmpSeconds(): + return PerfDataFormat::ampereSeconds($value); + case $this->isVolts(): + return PerfDataFormat::volts($value); + case $this->isOhms(): + return PerfDataFormat::ohms($value); + case $this->isGrams(): + return PerfDataFormat::grams($value); + case $this->isLiters(): + return PerfDataFormat::liters($value); + case ! is_numeric($value): + return $value; + default: + return number_format($value, 2) . ($this->unit !== null ? ' ' . $this->unit : ''); + } + } + + /** + * Format the title string that represents this perfdata set + * + * @param bool $html + * + * @return string + */ + public function formatLabel(bool $html = false): string + { + return sprintf( + $html ? '<b>%s %s</b> (%s%%)' : '%s %s (%s%%)', + htmlspecialchars($this->getLabel()), + $this->format($this->value), + number_format($this->getPercentage() ?? 0, 2) + ); + } + + public function toArray(): array + { + return array( + 'label' => $this->getLabel(), + 'value' => $this->format($this->getvalue()), + 'min' => isset($this->minValue) && !$this->isPercentage() + ? $this->format($this->minValue) + : '', + 'max' => isset($this->maxValue) && !$this->isPercentage() + ? $this->format($this->maxValue) + : '', + 'warn' => $this->format($this->warningThreshold), + 'crit' => $this->format($this->criticalThreshold) + ); + } + + /** + * Return the state indicated by this perfdata + * + * @return int + */ + public function getState(): int + { + if (! is_numeric($this->value)) { + return ServiceStates::UNKNOWN; + } + + if (! $this->criticalThreshold->contains($this->value)) { + return ServiceStates::CRITICAL; + } + + if (! $this->warningThreshold->contains($this->value)) { + return ServiceStates::WARNING; + } + + return ServiceStates::OK; + } + + /** + * Return whether the state indicated by this perfdata is worse than + * the state indicated by the other perfdata + * CRITICAL > UNKNOWN > WARNING > OK + * + * @param PerfData $rhs the other perfdata + * + * @return bool + */ + public function worseThan(PerfData $rhs): bool + { + if (($state = $this->getState()) === ($rhsState = $rhs->getState())) { + return $this->getPercentage() > $rhs->getPercentage(); + } + + if ($state === ServiceStates::CRITICAL) { + return true; + } + + if ($state === ServiceStates::UNKNOWN) { + return $rhsState !== ServiceStates::CRITICAL; + } + + if ($state === ServiceStates::WARNING) { + return $rhsState === ServiceStates::OK; + } + + return false; + } +} diff --git a/library/Icingadb/Util/PerfDataFormat.php b/library/Icingadb/Util/PerfDataFormat.php new file mode 100644 index 0000000..8c33ae5 --- /dev/null +++ b/library/Icingadb/Util/PerfDataFormat.php @@ -0,0 +1,172 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +class PerfDataFormat +{ + protected static $instance; + + protected static $generalBase = 1000; + + protected static $bitPrefix = ['b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb']; + + protected static $bytePrefix = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + protected static $wattHourPrefix = ['Wh', 'kWh', 'MWh', 'GWh', 'TWh', 'PWh', 'EWh', 'ZWh', 'YWh']; + + protected static $wattPrefix = [-1 => 'mW', 'W', 'kW', 'MW', 'GW']; + + protected static $amperePrefix = [-3 => 'nA', -2 => 'µA', -1 => 'mA', 'A', 'kA', 'MA', 'GA']; + + protected static $ampSecondPrefix = [-2 => 'µAs', -1 => 'mAs', 'As', 'kAs', 'MAs', 'GAs']; + + protected static $voltPrefix = [-2 => 'µV', -1 => 'mV', 'V', 'kV', 'MV', 'GV']; + + protected static $ohmPrefix = ['Ω']; + + protected static $gramPrefix = [ + -5 => 'fg', + -4 => 'pg', + -3 => 'ng', + -2 => 'µg', + -1 => 'mg', + 'g', + 'kg', + 't', + 'ktǂ', + 'Mt', + 'Gt' + ]; + + protected static $literPrefix = [ + -5 => 'fl', + -4 => 'pl', + -3 => 'nl', + -2 => 'µl', + -1 => 'ml', + 'l', + 'kl', + 'Ml', + 'Gl', + 'Tl', + 'Pl' + ]; + + protected static $secondPrefix = [-3 => 'ns', -2 => 'µs', -1 => 'ms', 's']; + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new PerfDataFormat(); + } + + return self::$instance; + } + + public static function bits($value): string + { + return self::formatForUnits($value, self::$bitPrefix, self::$generalBase); + } + + public static function bytes($value): string + { + return self::formatForUnits($value, self::$bytePrefix, self::$generalBase); + } + + public static function wattHours($value): string + { + return self::formatForUnits($value, self::$wattHourPrefix, self::$generalBase); + } + + public static function watts($value): string + { + return self::formatForUnits($value, self::$wattPrefix, self::$generalBase); + } + + public static function amperes($value): string + { + return self::formatForUnits($value, self::$amperePrefix, self::$generalBase); + } + + public static function ampereSeconds($value): string + { + return self::formatForUnits($value, self::$ampSecondPrefix, self::$generalBase); + } + + public static function volts($value): string + { + return self::formatForUnits($value, self::$voltPrefix, self::$generalBase); + } + + public static function ohms($value): string + { + return self::formatForUnits($value, self::$ohmPrefix, self::$generalBase); + } + + public static function grams($value): string + { + return self::formatForUnits($value, self::$gramPrefix, self::$generalBase); + } + + public static function liters($value): string + { + return self::formatForUnits($value, self::$literPrefix, self::$generalBase); + } + + public static function seconds($value): string + { + $value = (float) $value; + $absValue = abs($value); + + if ($absValue < 60) { + return self::formatForUnits($value, self::$secondPrefix, self::$generalBase); + } elseif ($absValue < 3600) { + return sprintf('%0.2f m', $value / 60); + } elseif ($absValue < 86400) { + return sprintf('%0.2f h', $value / 3600); + } + + return sprintf('%0.2f d', $value / 86400); + } + + protected static function formatForUnits($value, array &$units, int $base): string + { + $sign = ''; + $value = (float) $value; + if ($value < 0) { + $value = abs($value); + $sign = '-'; + } + + if ($value == 0) { + $pow = $result = 0; + } else { + $pow = floor(log($value, $base)); + + // Identify nearest unit if unknown + while (! isset($units[$pow])) { + if ($pow < 0) { + $pow++; + } else { + $pow--; + } + } + + $result = $value / pow($base, $pow); + } + + // 1034.23 looks better than 1.03, but 2.03 is fine: + if ($pow > 0 && $result < 2) { + $result = $value / pow($base, --$pow); + } + + return sprintf( + '%s%0.2f %s', + $sign, + $result, + $units[$pow] + ); + } +} diff --git a/library/Icingadb/Util/PerfDataSet.php b/library/Icingadb/Util/PerfDataSet.php new file mode 100644 index 0000000..6a18d99 --- /dev/null +++ b/library/Icingadb/Util/PerfDataSet.php @@ -0,0 +1,159 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use ArrayIterator; +use IteratorAggregate; + +class PerfDataSet implements IteratorAggregate +{ + /** + * The performance data being parsed + * + * @var string + */ + protected $perfdataStr; + + /** + * The current parsing position + * + * @var int + */ + protected $parserPos = 0; + + /** + * A list of PerfData objects + * + * @var array + */ + protected $perfdata = array(); + + /** + * Create a new set of performance data + * + * @param string $perfdataStr A space separated list of label/value pairs + */ + protected function __construct(string $perfdataStr) + { + if (($perfdataStr = trim($perfdataStr)) !== '') { + $this->perfdataStr = $perfdataStr; + $this->parse(); + } + } + + /** + * Return a iterator for this set of performance data + * + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->asArray()); + } + + /** + * Return a new set of performance data + * + * @param string $perfdataStr A space separated list of label/value pairs + * + * @return PerfDataSet + */ + public static function fromString(string $perfdataStr): self + { + return new static($perfdataStr); + } + + /** + * Return this set of performance data as array + * + * @return array + */ + public function asArray(): array + { + return $this->perfdata; + } + + /** + * Parse the current performance data + */ + protected function parse() + { + while ($this->parserPos < strlen($this->perfdataStr)) { + $label = trim($this->readLabel()); + $value = trim($this->readUntil(' ')); + + if ($label) { + $this->perfdata[] = new PerfData($label, $value); + } + } + + uasort( + $this->perfdata, + function ($a, $b) { + if ($a->isVisualizable() && ! $b->isVisualizable()) { + return -1; + } elseif (! $a->isVisualizable() && $b->isVisualizable()) { + return 1; + } elseif (! $a->isVisualizable() && ! $b->isVisualizable()) { + return 0; + } + + return $a->worseThan($b) ? -1 : ($b->worseThan($a) ? 1 : 0); + } + ); + } + + /** + * Return the next label found in the performance data + * + * @return string The label found + */ + protected function readLabel(): string + { + $this->skipSpaces(); + if (in_array($this->perfdataStr[$this->parserPos], array('"', "'"))) { + $quoteChar = $this->perfdataStr[$this->parserPos++]; + $label = $this->readUntil($quoteChar); + $this->parserPos++; + + if ($this->perfdataStr[$this->parserPos] === '=') { + $this->parserPos++; + } + } else { + $label = $this->readUntil('='); + $this->parserPos++; + } + + $this->skipSpaces(); + return $label; + } + + /** + * Return all characters between the current parser position and the given character + * + * @param string $stopChar The character on which to stop + * + * @return string + */ + protected function readUntil(string $stopChar): string + { + $start = $this->parserPos; + while ($this->parserPos < strlen($this->perfdataStr) && $this->perfdataStr[$this->parserPos] !== $stopChar) { + $this->parserPos++; + } + + return substr($this->perfdataStr, $start, $this->parserPos - $start); + } + + /** + * Advance the parser position to the next non-whitespace character + */ + protected function skipSpaces() + { + while ($this->parserPos < strlen($this->perfdataStr) && $this->perfdataStr[$this->parserPos] === ' ') { + $this->parserPos++; + } + } +} diff --git a/library/Icingadb/Util/PluginOutput.php b/library/Icingadb/Util/PluginOutput.php new file mode 100644 index 0000000..71d08b1 --- /dev/null +++ b/library/Icingadb/Util/PluginOutput.php @@ -0,0 +1,260 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use DOMDocument; +use DOMNode; +use DOMText; +use Icinga\Module\Icingadb\Hook\PluginOutputHook; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Web\Dom\DomNodeIterator; +use Icinga\Web\Helper\HtmlPurifier; +use InvalidArgumentException; +use ipl\Html\HtmlString; +use ipl\Orm\Model; +use LogicException; +use RecursiveIteratorIterator; + +class PluginOutput extends HtmlString +{ + /** @var string[] Patterns to be replaced in plain text plugin output */ + const TEXT_PATTERNS = [ + '~\\\t~', + '~\\\n~', + '~(\[|\()OK(\]|\))~', + '~(\[|\()WARNING(\]|\))~', + '~(\[|\()CRITICAL(\]|\))~', + '~(\[|\()UNKNOWN(\]|\))~', + '~(\[|\()UP(\]|\))~', + '~(\[|\()DOWN(\]|\))~', + '~\@{6,}~' + ]; + + /** @var string[] Replacements for {@see PluginOutput::TEXT_PATTERNS} */ + const TEXT_REPLACEMENTS = [ + "\t", + "\n", + '<span class="state-ball ball-size-m state-ok"></span>', + '<span class="state-ball ball-size-m state-warning"></span>', + '<span class="state-ball ball-size-m state-critical"></span>', + '<span class="state-ball ball-size-m state-unknown"></span>', + '<span class="state-ball ball-size-m state-up"></span>', + '<span class="state-ball ball-size-m state-down"></span>', + '@@@@@@' + ]; + + /** @var string[] Patterns to be replaced in html plugin output */ + const HTML_PATTERNS = [ + '~\\\t~', + '~\\\n~' + ]; + + /** @var string[] Replacements for {@see PluginOutput::HTML_PATTERNS} */ + const HTML_REPLACEMENTS = [ + "\t", + "\n" + ]; + + /** @var string Already rendered output */ + protected $renderedOutput; + + /** @var bool Whether the output contains HTML */ + protected $isHtml; + + /** @var bool Whether output will be enriched */ + protected $enrichOutput = true; + + /** @var string The name of the command that produced the output */ + protected $commandName; + + /** + * Get whether the output contains HTML + * + * Requires the output being already rendered. + * + * @return bool + * + * @throws LogicException In case the output hasn't been rendered yet + */ + public function isHtml(): bool + { + if ($this->isHtml === null) { + if (empty($this->getContent())) { + // "Nothing" can't be HTML + return false; + } + + throw new LogicException('Output not rendered yet'); + } + + return $this->isHtml; + } + + /** + * Set whether the output should be enriched + * + * @param bool $state + * + * @return $this + */ + public function setEnrichOutput(bool $state = true): self + { + $this->enrichOutput = $state; + + return $this; + } + + /** + * Set name of the command that produced the output + * + * @param string $name + * + * @return $this + */ + public function setCommandName(string $name): self + { + $this->commandName = $name; + + return $this; + } + + /** + * Render plugin output of the given object + * + * @param Host|Service $object + * + * @return static + * + * @throws InvalidArgumentException If $object is neither a host nor a service + */ + public static function fromObject(Model $object): self + { + if (! $object instanceof Host && ! $object instanceof Service) { + throw new InvalidArgumentException( + sprintf('Object is not a host or service, got %s instead', get_class($object)) + ); + } + + return (new static($object->state->output . "\n" . $object->state->long_output)) + ->setCommandName($object->checkcommand_name); + } + + public function render() + { + if ($this->renderedOutput !== null) { + return $this->renderedOutput; + } + + $output = parent::render(); + if (empty($output)) { + return ''; + } + + if ($this->commandName !== null) { + $output = PluginOutputHook::processOutput($output, $this->commandName, $this->enrichOutput); + } + + if (preg_match('~<\w+(?>\s\w+=[^>]*)?>~', $output)) { + // HTML + $output = HtmlPurifier::process(preg_replace( + self::HTML_PATTERNS, + self::HTML_REPLACEMENTS, + $output + )); + $this->isHtml = true; + } else { + // Plaintext + $output = preg_replace( + self::TEXT_PATTERNS, + self::TEXT_REPLACEMENTS, + htmlspecialchars($output, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, null, false) + ); + $this->isHtml = false; + } + + $output = trim($output); + + // Add zero-width space after commas which are not followed by a whitespace character + // in oder to help browsers to break words in plugin output + $output = preg_replace('/,(?=[^\s])/', ',​', $output); + + if ($this->enrichOutput && $this->isHtml) { + $output = $this->processHtml($output); + } + + $this->renderedOutput = $output; + + return $output; + } + + /** + * Replace color state information, if any + * + * @param string $html + * + * @todo Do we really need to create a DOM here? Or is a preg_replace like we do it for text also feasible? + * @return string + */ + protected function processHtml(string $html): string + { + $pattern = '/[([](OK|WARNING|CRITICAL|UNKNOWN|UP|DOWN)[)\]]/'; + $doc = new DOMDocument(); + $doc->loadXML('<div>' . $html . '</div>', LIBXML_NOERROR | LIBXML_NOWARNING); + $dom = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST); + + $nodesToRemove = []; + foreach ($dom as $node) { + /** @var DOMNode $node */ + if ($node->nodeType !== XML_TEXT_NODE) { + continue; + } + + $start = 0; + while (preg_match($pattern, $node->nodeValue, $match, PREG_OFFSET_CAPTURE, $start)) { + $offsetLeft = $match[0][1]; + $matchLength = strlen($match[0][0]); + $leftLength = $offsetLeft - $start; + + // if there is text before the match + if ($leftLength) { + // create node for leading text + $text = new DOMText(substr($node->nodeValue, $start, $leftLength)); + $node->parentNode->insertBefore($text, $node); + } + + // create the state ball for the match + $span = $doc->createElement('span'); + $span->setAttribute( + 'class', + 'state-ball ball-size-m state-' . strtolower($match[1][0]) + ); + $node->parentNode->insertBefore($span, $node); + + // start for next match + $start = $offsetLeft + $matchLength; + } + + if ($start) { + // is there text left? + if (strlen($node->nodeValue) > $start) { + // create node for trailing text + $text = new DOMText(substr($node->nodeValue, $start)); + $node->parentNode->insertBefore($text, $node); + } + + // delete the old node later + $nodesToRemove[] = $node; + } + } + + foreach ($nodesToRemove as $node) { + /** @var DOMNode $node */ + $node->parentNode->removeChild($node); + } + + return substr($doc->saveHTML(), 5, -7); + } +} diff --git a/library/Icingadb/Util/ThresholdRange.php b/library/Icingadb/Util/ThresholdRange.php new file mode 100644 index 0000000..c92842d --- /dev/null +++ b/library/Icingadb/Util/ThresholdRange.php @@ -0,0 +1,180 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +/** + * The warning/critical threshold of a measured value + */ +class ThresholdRange +{ + /** + * The smallest value inside the range (null stands for -∞) + * + * @var float|null + */ + protected $min; + + /** + * The biggest value inside the range (null stands for ∞) + * + * @var float|null + */ + protected $max; + + /** + * Whether to invert the result of contains() + * + * @var bool + */ + protected $inverted = false; + + /** + * The unmodified range as passed to fromString() + * + * @var string + */ + protected $raw; + + /** + * Create a new instance based on a threshold range conforming to <https://nagios-plugins.org/doc/guidelines.html> + * + * @param string $rawRange + * + * @return ThresholdRange + */ + public static function fromString(string $rawRange): self + { + $range = new static(); + $range->raw = $rawRange; + + if ($rawRange == '') { + return $range; + } + + $rawRange = ltrim($rawRange); + if (substr($rawRange, 0, 1) === '@') { + $range->setInverted(); + $rawRange = substr($rawRange, 1); + } + + if (strpos($rawRange, ':') === false) { + $min = 0.0; + $max = floatval(trim($rawRange)); + } else { + list($min, $max) = explode(':', $rawRange, 2); + $min = trim($min); + $max = trim($max); + + switch ($min) { + case '': + $min = 0.0; + break; + case '~': + $min = null; + break; + default: + $min = floatval($min); + } + + $max = empty($max) ? null : floatval($max); + } + + return $range->setMin($min) + ->setMax($max); + } + + /** + * Set the smallest value inside the range (null stands for -∞) + * + * @param float|null $min + * + * @return $this + */ + public function setMin(float $min): self + { + $this->min = $min; + return $this; + } + + /** + * Get the smallest value inside the range (null stands for -∞) + * + * @return float|null + */ + public function getMin() + { + return $this->min; + } + + /** + * Set the biggest value inside the range (null stands for ∞) + * + * @param float|null $max + * + * @return $this + */ + public function setMax(float $max): self + { + $this->max = $max; + return $this; + } + + /** + * Get the biggest value inside the range (null stands for ∞) + * + * @return float|null + */ + public function getMax() + { + return $this->max; + } + + /** + * Set whether to invert the result of contains() + * + * @param bool $inverted + * + * @return $this + */ + public function setInverted(bool $inverted = true): self + { + $this->inverted = $inverted; + return $this; + } + + /** + * Get whether to invert the result of contains() + * + * @return bool + */ + public function isInverted(): bool + { + return $this->inverted; + } + + /** + * Return whether $value is inside $this + * + * @param float $value + * + * @return bool + */ + public function contains(float $value): bool + { + return (bool) ($this->inverted ^ ( + ($this->min === null || $this->min <= $value) && ($this->max === null || $this->max >= $value) + )); + } + + /** + * Return the textual representation of $this, suitable for fromString() + * + * @return string + */ + public function __toString() + { + return (string) $this->raw; + } +} |