summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Util
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icingadb/Util/FeatureStatus.php50
-rw-r--r--library/Icingadb/Util/ObjectSuggestionsCursor.php25
-rw-r--r--library/Icingadb/Util/PerfData.php703
-rw-r--r--library/Icingadb/Util/PerfDataFormat.php171
-rw-r--r--library/Icingadb/Util/PerfDataSet.php172
-rw-r--r--library/Icingadb/Util/PluginOutput.php260
-rw-r--r--library/Icingadb/Util/ThresholdRange.php213
7 files changed, 1594 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..cc33d16
--- /dev/null
+++ b/library/Icingadb/Util/PerfData.php
@@ -0,0 +1,703 @@
+<?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;
+
+ /**
+ * The raw value
+ *
+ * @var ?string
+ */
+ protected $rawValue;
+
+ /**
+ * The raw minimum value
+ *
+ * @var ?string
+ */
+ protected $rawMinValue;
+
+ /**
+ * The raw maximum value
+ *
+ * @var ?string
+ */
+ protected $rawMaxValue;
+
+ /**
+ * 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, $this->maxValue, $this->value) && $this->isValid();
+ }
+
+ /**
+ * 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('@^(U|-?(?:\d+)?(?:\.\d+)?)([a-zA-TV-Z%°]{1,3})$@u', $parts[0], $matches)) {
+ $this->unit = $matches[2];
+ $value = $matches[1];
+ } else {
+ $value = $parts[0];
+ }
+
+ if (! is_numeric($value)) {
+ if ($value !== 'U') {
+ $this->rawValue = $parts[0];
+ }
+
+ $this->value = null;
+ } else {
+ $this->value = floatval($value);
+ }
+
+ switch (count($parts)) {
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 5:
+ if ($parts[4] !== '') {
+ if (is_numeric($parts[4])) {
+ $this->maxValue = floatval($parts[4]);
+ } else {
+ $this->rawMaxValue = $parts[4];
+ }
+ }
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 4:
+ if ($parts[3] !== '') {
+ if (is_numeric($parts[3])) {
+ $this->minValue = floatval($parts[3]);
+ } else {
+ $this->rawMinValue = $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->isValid()) {
+ return (string) $value;
+ }
+
+ 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 [
+ 'label' => $this->getLabel(),
+ 'value' => isset($this->value) ? $this->format($this->value) : $this->rawValue,
+ 'min' => (string) (
+ ! $this->isPercentage()
+ ? (isset($this->minValue) ? $this->format($this->minValue) : $this->rawMinValue)
+ : null
+ ),
+ 'max' => (string) (
+ ! $this->isPercentage()
+ ? (isset($this->maxValue) ? $this->format($this->maxValue) : $this->rawMaxValue)
+ : null
+ ),
+ '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;
+ }
+
+ /**
+ * Returns whether the performance data can be evaluated
+ *
+ * @return bool
+ */
+ public function isValid(): bool
+ {
+ return ! isset($this->rawValue)
+ && ! isset($this->rawMinValue)
+ && ! isset($this->rawMaxValue)
+ && $this->criticalThreshold->isValid()
+ && $this->warningThreshold->isValid();
+ }
+}
diff --git a/library/Icingadb/Util/PerfDataFormat.php b/library/Icingadb/Util/PerfDataFormat.php
new file mode 100644
index 0000000..1caffff
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataFormat.php
@@ -0,0 +1,171 @@
+<?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(float $value, array &$units, int $base): string
+ {
+ $sign = '';
+ 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..df31393
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataSet.php
@@ -0,0 +1,172 @@
+<?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
+ * @param string $backtrackOn The character on which to backtrack
+ *
+ * @return string
+ */
+ protected function readUntil(string $stopChar, string $backtrackOn = null): string
+ {
+ $start = $this->parserPos;
+ $breakCharEncounteredAt = null;
+ $stringExhaustedAt = strlen($this->perfdataStr);
+ while ($this->parserPos < $stringExhaustedAt) {
+ if ($this->perfdataStr[$this->parserPos] === $stopChar) {
+ break;
+ } elseif ($breakCharEncounteredAt === null && $this->perfdataStr[$this->parserPos] === $backtrackOn) {
+ $breakCharEncounteredAt = $this->parserPos;
+ }
+
+ $this->parserPos++;
+ }
+
+ if ($breakCharEncounteredAt !== null && $this->parserPos === $stringExhaustedAt) {
+ $this->parserPos = $breakCharEncounteredAt;
+ }
+
+ 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])/', ',&#8203;', $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..675697a
--- /dev/null
+++ b/library/Icingadb/Util/ThresholdRange.php
@@ -0,0 +1,213 @@
+<?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;
+
+ /**
+ * Whether the threshold range is valid
+ *
+ * @var bool
+ */
+ protected $isValid = true;
+
+ /**
+ * 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 = trim($rawRange);
+ if (! is_numeric($max)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $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:
+ if (! is_numeric($min)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $min = floatval($min);
+ }
+
+ if (! empty($max) && ! is_numeric($max)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $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 whether the threshold range is valid
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return $this->isValid;
+ }
+
+ /**
+ * Return the textual representation of $this, suitable for fromString()
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return (string) $this->raw;
+ }
+}