From 351a5f4f353239ce778c0144ea3da5171c4b1d43 Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Thu, 22 Apr 2021 10:34:32 +0200 Subject: [PATCH] Re-add perfdata pie charts in list views (#176) --- .../Common/ListItemDetailedLayout.php | 23 + library/Icingadb/Util/PerfData.php | 550 ++++++++++++++++++ library/Icingadb/Util/PerfDataSet.php | 144 +++++ library/Icingadb/Util/ThresholdRange.php | 180 ++++++ .../Icingadb/Widget/Detail/ObjectDetail.php | 10 +- .../Icingadb/Widget/HostListItemDetailed.php | 70 ++- library/Icingadb/Widget/PerfDataTable.php | 140 +++++ .../Widget/ServiceListItemDetailed.php | 71 ++- library/Icingadb/Widget/StateListItem.php | 10 + public/css/lists.less | 28 + public/css/widget/performance-data-table.less | 57 ++ 11 files changed, 1273 insertions(+), 10 deletions(-) create mode 100644 library/Icingadb/Common/ListItemDetailedLayout.php create mode 100644 library/Icingadb/Util/PerfData.php create mode 100644 library/Icingadb/Util/PerfDataSet.php create mode 100644 library/Icingadb/Util/ThresholdRange.php create mode 100644 library/Icingadb/Widget/PerfDataTable.php create mode 100644 public/css/widget/performance-data-table.less diff --git a/library/Icingadb/Common/ListItemDetailedLayout.php b/library/Icingadb/Common/ListItemDetailedLayout.php new file mode 100644 index 00000000..3db91a33 --- /dev/null +++ b/library/Icingadb/Common/ListItemDetailedLayout.php @@ -0,0 +1,23 @@ +add($this->createTitle()); + $header->add($this->createTimestamp()); + } + + protected function assembleMain(BaseHtmlElement $main) + { + $main->add($this->createHeader()); + $main->add($this->createCaption()); + $main->add($this->createFooter()); + } +} diff --git a/library/Icingadb/Util/PerfData.php b/library/Icingadb/Util/PerfData.php new file mode 100644 index 00000000..b0b14a79 --- /dev/null +++ b/library/Icingadb/Util/PerfData.php @@ -0,0 +1,550 @@ +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($perfdata) + { + 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() + { + 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() + { + return in_array($this->unit, array('s', 'ms', 'us')); + } + + /** + * Return whether this performance data's value is a temperature + * + * @return bool True in case it's temperature, otherwise False + */ + public function isTemperature() + { + return in_array($this->unit, array('°c', '°f')); + } + + /** + * Return whether this performance data's value is in percentage + * + * @return bool True in case it's in percentage, otherwise False + */ + public function isPercentage() + { + return $this->unit === '%'; + } + + /** + * Return whether this performance data's value is in bytes + * + * @return bool True in case it's in bytes, otherwise False + */ + public function isBytes() + { + return in_array($this->unit, array('b', 'kb', 'mb', 'gb', 'tb')); + } + + /** + * Return whether this performance data's value is a counter + * + * @return bool True in case it's a counter, otherwise False + */ + public function isCounter() + { + 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() + { + return isset($this->minValue) && isset($this->maxValue) && isset($this->value); + } + + /** + * Return this perfomance data's label + */ + public function getLabel() + { + 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() + { + return $this->warningThreshold; + } + + /** + * Return this performance data's critical treshold + * + * @return ThresholdRange + */ + public function getCriticalThreshold() + { + return $this->criticalThreshold; + } + + /** + * Return the minimum value or null if it is not available + * + * @return null|string + */ + 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,2})$@u', $parts[0], $matches)) { + $this->unit = strtolower($matches[3]); + $this->value = self::convert($matches[1], $this->unit); + } else { + $this->value = self::convert($parts[0]); + } + + switch (count($parts)) { + /* @noinspection PhpMissingBreakStatementInspection */ + case 5: + if ($parts[4] !== '') { + $this->maxValue = self::convert($parts[4], $this->unit); + } + /* @noinspection PhpMissingBreakStatementInspection */ + case 4: + if ($parts[3] !== '') { + $this->minValue = self::convert($parts[3], $this->unit); + } + /* @noinspection PhpMissingBreakStatementInspection */ + case 3: + $this->criticalThreshold = self::convert( + ThresholdRange::fromString(trim($parts[2])), + $this->unit + ); + // Fallthrough + case 2: + $this->warningThreshold = self::convert( + ThresholdRange::fromString(trim($parts[1])), + $this->unit + ); + } + + if ($this->warningThreshold === null) { + $this->warningThreshold = new ThresholdRange(); + } + if ($this->criticalThreshold === null) { + $this->criticalThreshold = new ThresholdRange(); + } + } + + /** + * Return the given value converted to its smallest supported representation + * + * @param string $value The value to convert + * @param string $fromUnit The unit the value currently represents + * + * @return null|float Null in case the value is not a number + */ + protected static function convert($value, $fromUnit = null) + { + if ($value instanceof ThresholdRange) { + $value = clone $value; + + $min = $value->getMin(); + if ($min !== null) { + $value->setMin(self::convert($min, $fromUnit)); + } + + $max = $value->getMax(); + if ($max !== null) { + $value->setMax(self::convert($max, $fromUnit)); + } + + return $value; + } + + if (is_numeric($value)) { + switch ($fromUnit) { + case 'us': + return $value / pow(10, 6); + case 'ms': + return $value / pow(10, 3); + case 'tb': + return floatval($value) * pow(2, 40); + case 'gb': + return floatval($value) * pow(2, 30); + case 'mb': + return floatval($value) * pow(2, 20); + case 'kb': + return floatval($value) * pow(2, 10); + default: + return (float) $value; + } + } + } + + protected function calculatePieChartData() + { + $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() + { + if (! $this->isVisualizable()) { + throw new ProgrammingError('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); + } + + if ($this->isPercentage()) { + return (string)$value . '%'; + } + if ($this->isBytes()) { + return Format::bytes($value); + } + if ($this->isSeconds()) { + return Format::seconds($value); + } + if ($this->isTemperature()) { + return (string)$value . strtoupper($this->unit); + } + return number_format($value, 2); + } + + /** + * Format the title string that represents this perfdata set + * + * @param bool $html + * + * @return string + */ + public function formatLabel($html = false) + { + return sprintf( + $html ? '%s %s (%s%%)' : '%s %s (%s%%)', + htmlspecialchars($this->getLabel()), + $this->format($this->value), + number_format($this->getPercentage(), 2) + ); + } + + public function toArray() + { + 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 + * + * @see Service + * + * @return int + */ + public function getState() + { + if ($this->value === null) { + return Service::STATE_UNKNOWN; + } + + if (! $this->criticalThreshold->contains($this->value)) { + return Service::STATE_CRITICAL; + } + + if (! $this->warningThreshold->contains($this->value)) { + return Service::STATE_WARNING; + } + + return Service::STATE_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) + { + if (($state = $this->getState()) === ($rhsState = $rhs->getState())) { + return $this->getPercentage() > $rhs->getPercentage(); + } + + if ($state === Service::STATE_CRITICAL) { + return true; + } + + if ($state === Service::STATE_UNKNOWN) { + return $rhsState !== Service::STATE_CRITICAL; + } + + if ($state === Service::STATE_WARNING) { + return $rhsState === Service::STATE_OK; + } + + return false; + } +} diff --git a/library/Icingadb/Util/PerfDataSet.php b/library/Icingadb/Util/PerfDataSet.php new file mode 100644 index 00000000..dcd18080 --- /dev/null +++ b/library/Icingadb/Util/PerfDataSet.php @@ -0,0 +1,144 @@ +perfdataStr = $perfdataStr; + $this->parse(); + } + } + + /** + * Return a iterator for this set of performance data + * + * @return ArrayIterator + */ + public function getIterator() + { + 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($perfdataStr) + { + return new static($perfdataStr); + } + + /** + * Return this set of performance data as array + * + * @return array + */ + public function asArray() + { + 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); + } + } + } + + /** + * Return the next label found in the performance data + * + * @return string The label found + */ + protected function readLabel() + { + $this->skipSpaces(); + if (in_array($this->perfdataStr[$this->parserPos], array('"', "'"))) { + $quoteChar = $this->perfdataStr[$this->parserPos++]; + $label = $this->readUntil('='); + $this->parserPos++; + + if (($closingPos = strpos($label, $quoteChar)) > 0) { + $label = substr($label, 0, $closingPos); + } + } 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($stopChar) + { + $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/ThresholdRange.php b/library/Icingadb/Util/ThresholdRange.php new file mode 100644 index 00000000..c97a023d --- /dev/null +++ b/library/Icingadb/Util/ThresholdRange.php @@ -0,0 +1,180 @@ + + * + * @param string $rawRange + * + * @return ThresholdRange + */ + public static function fromString($rawRange) + { + $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($min) + { + $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($max) + { + $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($inverted = true) + { + $this->inverted = $inverted; + return $this; + } + + /** + * Get whether to invert the result of contains() + * + * @return bool + */ + public function isInverted() + { + return $this->inverted; + } + + /** + * Return whether $value is inside $this + * + * @param float $value + * + * @return bool + */ + public function contains($value) + { + 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; + } +} diff --git a/library/Icingadb/Widget/Detail/ObjectDetail.php b/library/Icingadb/Widget/Detail/ObjectDetail.php index a41e7991..2eac2ad2 100644 --- a/library/Icingadb/Widget/Detail/ObjectDetail.php +++ b/library/Icingadb/Widget/Detail/ObjectDetail.php @@ -22,6 +22,7 @@ use Icinga\Module\Icingadb\Widget\DowntimeList; use Icinga\Module\Icingadb\Widget\EmptyState; use Icinga\Module\Icingadb\Widget\HorizontalKeyValue; use Icinga\Module\Icingadb\Widget\ItemList\CommentList; +use Icinga\Module\Icingadb\Widget\PerfDataTable; use Icinga\Module\Icingadb\Widget\ShowMore; use Icinga\Module\Icingadb\Widget\TagList; use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook; @@ -35,7 +36,6 @@ use ipl\Html\HtmlString; use ipl\Orm\ResultSet; use ipl\Stdlib\Filter; use ipl\Web\Widget\Icon; -use Zend_View_Helper_Perfdata; class ObjectDetail extends BaseHtmlElement { @@ -300,12 +300,6 @@ class ObjectDetail extends BaseHtmlElement protected function createPerformanceData() { - require_once Icinga::app()->getModuleManager()->getModule('monitoring')->getBaseDir() - . '/application/views/helpers/Perfdata.php'; - - $helper = new Zend_View_Helper_Perfdata(); - $helper->view = Icinga::app()->getViewRenderer()->view; - $content[] = Html::tag('h2', t('Performance Data')); if (empty($this->object->state->performance_data)) { @@ -314,7 +308,7 @@ class ObjectDetail extends BaseHtmlElement $content[] = new HtmlElement( 'div', ['id' => 'check-perfdata-' . $this->object->checkcommand], - new HtmlString($helper->perfdata($this->object->state->performance_data)) + new PerfDataTable($this->object->state->performance_data) ); } diff --git a/library/Icingadb/Widget/HostListItemDetailed.php b/library/Icingadb/Widget/HostListItemDetailed.php index 68e4dd69..2a2fb9be 100644 --- a/library/Icingadb/Widget/HostListItemDetailed.php +++ b/library/Icingadb/Widget/HostListItemDetailed.php @@ -4,11 +4,79 @@ namespace Icinga\Module\Icingadb\Widget; +use Icinga\Module\Icingadb\Common\ListItemDetailedLayout; use Icinga\Module\Icingadb\Compat\CompatPluginOutput; +use Icinga\Module\Icingadb\Util\PerfDataSet; use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; -class HostListItemDetailed extends HostListItem +class HostListItemDetailed extends BaseHostListItem { + use ListItemDetailedLayout; + + /** @var int Max pie charts to be shown */ + const PIE_CHART_LIMIT = 5; + + protected function getStateBallSize() + { + return StateBall::SIZE_LARGE; + } + + protected function assembleFooter(BaseHtmlElement $footer) + { + $statusIcons = new HtmlElement('div', ['class' => 'status-icons']); + + // ToDo(fs): Get `has_comments` from database + if ($this->item->comment->limit(1)->execute()->hasResult()) { + $statusIcons->add(new Icon('comments', ['title' => t('This item has been commented')])); + } + + if (! $this->item->notifications_enabled) { + $statusIcons->add(new Icon('bell-slash', ['title' => t('Notifications disabled')])); + } + + if (! $this->item->active_checks_enabled) { + $statusIcons->add(new Icon('eye-slash', ['title' => t('Active checks disabled')])); + } + + $performanceData = new HtmlElement('div', ['class' => 'performance-data']); + if ($this->item->state->performance_data) { + $pieChartData = PerfDataSet::fromString($this->item->state->performance_data)->asArray(); + + $pies = []; + foreach ($pieChartData as $i => $perfdata) { + if ($perfdata->isVisualizable()) { + $pies[] = $perfdata->asInlinePie()->render(); + } + + // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT + if (count($pies) > HostListItemDetailed::PIE_CHART_LIMIT) { + break; + } + } + + $maxVisiblePies = HostListItemDetailed::PIE_CHART_LIMIT - 2; + $numOfPies = count($pies); + foreach ($pies as $i => $pie) { + if ( + // Show max. 5 elements: if there are more than 5, show 4 + `…` + $i > $maxVisiblePies && $numOfPies > HostListItemDetailed::PIE_CHART_LIMIT + ) { + $performanceData->add(new HtmlElement('span', [], '…')); + break; + } + + $performanceData->add(HtmlString::create($pie)); + } + } + + $footer->add($statusIcons); + $footer->add($performanceData); + } + protected function assembleCaption(BaseHtmlElement $caption) { $caption->add(CompatPluginOutput::getInstance()->render( diff --git a/library/Icingadb/Widget/PerfDataTable.php b/library/Icingadb/Widget/PerfDataTable.php new file mode 100644 index 00000000..070fa252 --- /dev/null +++ b/library/Icingadb/Widget/PerfDataTable.php @@ -0,0 +1,140 @@ + 'performance-data-table collapsible', + 'data-visible-rows' => 6 + ]; + + /** @var string The perfdata string */ + protected $perfdataStr; + + /** @var int Max labels to show; 0 for no limit */ + protected $limit; + + /** @var string The color indicating the perfdata state */ + protected $color; + + /** + * Display the given perfdata string to the user + * + * @param string $perfdataStr The perfdata string + * @param int $limit Max labels to show; 0 for no limit + * @param string $color The color indicating the perfdata state + * + * @return string + */ + public function __construct($perfdataStr, $limit = 0, $color = PerfData::PERFDATA_OK) + { + $this->perfdataStr = $perfdataStr; + $this->limit = $limit; + $this->color = $color; + } + + public function assemble() + { + $pieChartData = PerfDataSet::fromString($this->perfdataStr)->asArray(); + uasort( + $pieChartData, + function ($a, $b) { + return $a->worseThan($b) ? -1 : ($b->worseThan($a) ? 1 : 0); + } + ); + $keys = ['', 'label', 'value', 'min', 'max', 'warn', 'crit']; + $columns = []; + $labels = array_combine( + $keys, + [ + '', + t('Label'), + t('Value'), + t('Min'), + t('Max'), + t('Warning'), + t('Critical') + ] + ); + foreach ($pieChartData as $perfdata) { + if ($perfdata->isVisualizable()) { + $columns[''] = ''; + $this->containsSparkline = true; + } + + foreach ($perfdata->toArray() as $column => $value) { + if ( + empty($value) || + $column === 'min' && floatval($value) === 0.0 || + $column === 'max' && $perfdata->isPercentage() && floatval($value) === 100 + ) { + continue; + } + + $columns[$column] = $labels[$column]; + } + } + + $headerRow = new HtmlElement('tr'); + foreach ($keys as $key => $col) { + if ((! $this->containsSparkline) && $col == '') { + unset($keys[$key]); + continue; + } + if (isset($col)) { + $headerRow->add(new HtmlElement('th', [ + 'class' => ($col == 'label' ? 'title' : null) + ], $labels[$col])); + } + } + + $this->getHeader()->add($headerRow); + + foreach ($pieChartData as $count => $perfdata) { + if ($this->limit != 0 && $count > $this->limit) { + break; + } else { + $cols = []; + if ($this->containsSparkline) { + if ($perfdata->isVisualizable()) { + $cols[] = Table::td( + HtmlString::create($perfdata->asInlinePie($this->color)->render()), + [ 'class' => 'sparkline-col'] + ); + } else { + $cols[] = Table::td(''); + } + } + + foreach ($perfdata->toArray() as $column => $value) { + $text = htmlspecialchars(empty($value) ? '-' : $value); + $cols[] = Table::td( + new HtmlElement( + 'span', + [ + 'title' => ($text == '-' ? t('no value given') : $text), + 'class' => ($text != '-' ?: 'no-value') + ], + $text + ), + [ 'class' => ($column == 'label' ? 'title' : null) ] + ); + } + + $this->add(Table::tr([$cols])); + } + } + } +} diff --git a/library/Icingadb/Widget/ServiceListItemDetailed.php b/library/Icingadb/Widget/ServiceListItemDetailed.php index f05b2352..a6a51e77 100644 --- a/library/Icingadb/Widget/ServiceListItemDetailed.php +++ b/library/Icingadb/Widget/ServiceListItemDetailed.php @@ -4,11 +4,80 @@ namespace Icinga\Module\Icingadb\Widget; +use Icinga\Module\Icingadb\Common\ListItemDetailedLayout; use Icinga\Module\Icingadb\Compat\CompatPluginOutput; +use Icinga\Module\Icingadb\Util\PerfDataSet; use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; -class ServiceListItemDetailed extends ServiceListItem +class ServiceListItemDetailed extends BaseServiceListItem { + use ListItemDetailedLayout; + + /** @var int Max pie charts to be shown */ + const PIE_CHART_LIMIT = 5; + + protected function getStateBallSize() + { + return StateBall::SIZE_LARGE; + } + + protected function assembleFooter(BaseHtmlElement $footer) + { + $statusIcons = new HtmlElement('div', ['class' => 'status-icons']); + + // ToDo(fs): Get `has_comments` from database + if ($this->item->comment->limit(1)->execute()->hasResult()) { + $statusIcons->add(new Icon('comments', ['title' => t('This item has been commented')])); + } + + if (! $this->item->notifications_enabled) { + $statusIcons->add(new Icon('bell-slash', ['title' => t('Notifications disabled')])); + } + + if (! $this->item->active_checks_enabled) { + $statusIcons->add(new Icon('eye-slash', ['title' => t('Active checks disabled')])); + } + + $performanceData = new HtmlElement('div', ['class' => 'performance-data']); + if ($this->item->state->performance_data) { + $pieChartData = PerfDataSet::fromString($this->item->state->performance_data)->asArray(); + + $pies = []; + foreach ($pieChartData as $i => $perfdata) { + if ($perfdata->isVisualizable()) { + $pies[] = $perfdata->asInlinePie()->render(); + } + + // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT + if (count($pies) > ServiceListItemDetailed::PIE_CHART_LIMIT) { + break; + } + } + + $maxVisiblePies = ServiceListItemDetailed::PIE_CHART_LIMIT - 2; + $numOfPies = count($pies); + foreach ($pies as $i => $pie) { + if ( + // Show max. 5 elements: if there are more than 5, show 4 + `…` + $i > $maxVisiblePies && $numOfPies > ServiceListItemDetailed::PIE_CHART_LIMIT + ) { + $performanceData->add(new HtmlElement('span', [], '…')); + break; + } + + $performanceData->add(HtmlString::create($pie)); + } + } + + $footer->add($statusIcons); + $footer->add($performanceData); + } + protected function assembleCaption(BaseHtmlElement $caption) { $caption->add(CompatPluginOutput::getInstance()->render( diff --git a/library/Icingadb/Widget/StateListItem.php b/library/Icingadb/Widget/StateListItem.php index f4b4e8a8..a5dededc 100644 --- a/library/Icingadb/Widget/StateListItem.php +++ b/library/Icingadb/Widget/StateListItem.php @@ -9,6 +9,7 @@ use Icinga\Module\Icingadb\Compat\CompatPluginOutput; use Icinga\Module\Icingadb\Model\State; use ipl\Html\BaseHtmlElement; use ipl\Html\Html; +use ipl\Html\HtmlElement; use ipl\Html\Text; use ipl\Web\Widget\Icon; use ipl\Web\Widget\StateBall; @@ -83,6 +84,15 @@ abstract class StateListItem extends BaseListItem } } + protected function createFooter() + { + $footer = new HtmlElement('div', ['class' => 'footer']); + + $this->assembleFooter($footer); + + return $footer; + } + protected function createTimestamp() { if ($this->state->is_overdue) { diff --git a/public/css/lists.less b/public/css/lists.less index 452cd780..eca163c7 100644 --- a/public/css/lists.less +++ b/public/css/lists.less @@ -124,6 +124,32 @@ margin: 0; } } + + .footer { + display: flex; + justify-content: space-between; + + > * { + font-size: .857em; + line-height: 1.5/.857em; + } + + .status-icons { + color: @gray-light; + } + + .performance-data { + .inline-pie { + display: inline-block; + line-height: 1.5/.857em; + width: 1em; + + &:not(:last-child) { + margin-right: .209em; + } + } + } + } } .content.full-width .list-item { @@ -186,6 +212,8 @@ .caption { display: block; height: auto; + max-height: 7.5em; /* 5 lines */ + position: relative; } } diff --git a/public/css/widget/performance-data-table.less b/public/css/widget/performance-data-table.less new file mode 100644 index 00000000..0ba7001e --- /dev/null +++ b/public/css/widget/performance-data-table.less @@ -0,0 +1,57 @@ +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +.performance-data-table { + width: 100%; + overflow: auto; + display: block; + + tr:not(:last-child) { + border-bottom: 1px solid @gray-lighter; + } + + td { + text-align: right; + .text-ellipsis(); + } + + th { + font-size: .857em; + font-weight: normal; + text-transform: uppercase; + letter-spacing: .05em; + } + + thead { + border-bottom: 1px solid @gray-light; + } + + th:first-child, + td:first-child { + padding-left: 0; + } + + .title { + text-align: left; + width: 100%; + } + + td.title { + font-weight: bold; + } + + .sparkline-col { + min-width: 1.75em; + width: 1.75em; + padding: 2/12em 0; + } + + .inline-pie > svg { + vertical-align: middle; + } + + .no-value { + opacity: .6; + text-align: center; + display: block; + } +}