diff --git a/modules/monitoring/application/views/scripts/partials/object/detail-content.phtml b/modules/monitoring/application/views/scripts/partials/object/detail-content.phtml
index 0dc4572ec..62bfd2c4e 100644
--- a/modules/monitoring/application/views/scripts/partials/object/detail-content.phtml
+++ b/modules/monitoring/application/views/scripts/partials/object/detail-content.phtml
@@ -45,12 +45,9 @@
customvars)): ?>
= $this->translate('Custom Variables') ?>
-
-
- = $this->render('show/components/customvars.phtml') ?>
-
-
+
+ = (new \Icinga\Module\Monitoring\Web\Widget\CustomVarTable($object->customvarsWithOriginalNames, $object)) ?>
+
-
= $this->render('show/components/flags.phtml') ?>
diff --git a/modules/monitoring/application/views/scripts/show/components/customvars.phtml b/modules/monitoring/application/views/scripts/show/components/customvars.phtml
deleted file mode 100644
index d780af8bb..000000000
--- a/modules/monitoring/application/views/scripts/show/components/customvars.phtml
+++ /dev/null
@@ -1,6 +0,0 @@
-customvarsWithOriginalNames as $name => $value): ?>
-
- = $this->escape($name) ?> |
- = $this->customvar($value) ?> |
-
-
diff --git a/modules/monitoring/library/Monitoring/Hook/CustomVarRendererHook.php b/modules/monitoring/library/Monitoring/Hook/CustomVarRendererHook.php
new file mode 100644
index 000000000..700bfd555
--- /dev/null
+++ b/modules/monitoring/library/Monitoring/Hook/CustomVarRendererHook.php
@@ -0,0 +1,98 @@
+prefetchForObject($object)) {
+ $hooks[] = $hook;
+ }
+ } catch (Exception $e) {
+ Logger::error('Failed to load hook %s:', get_class($hook), $e);
+ }
+ }
+
+ return function ($key, $value) use ($hooks, $object) {
+ $newKey = $key;
+ $newValue = $value;
+ $group = null;
+ foreach ($hooks as $hook) {
+ /** @var self $hook */
+
+ try {
+ $renderedKey = $hook->renderCustomVarKey($key);
+ $renderedValue = $hook->renderCustomVarValue($key, $value);
+ $group = $hook->identifyCustomVarGroup($key);
+ } catch (Exception $e) {
+ Logger::error('Failed to use hook %s:', get_class($hook), $e);
+ continue;
+ }
+
+ if ($renderedKey !== null || $renderedValue !== null) {
+ $newKey = $renderedKey !== null ? $renderedKey : $key;
+ $newValue = $renderedValue !== null ? $renderedValue : $value;
+ break;
+ }
+ }
+
+ return [$newKey, $newValue, $group];
+ };
+ }
+}
diff --git a/modules/monitoring/library/Monitoring/Web/Widget/CustomVarTable.php b/modules/monitoring/library/Monitoring/Web/Widget/CustomVarTable.php
new file mode 100644
index 000000000..4cbdad52c
--- /dev/null
+++ b/modules/monitoring/library/Monitoring/Web/Widget/CustomVarTable.php
@@ -0,0 +1,270 @@
+ ['custom-var-table', 'name-value-table']
+ ];
+
+ /**
+ * Create a new CustomVarTable
+ *
+ * @param iterable $data
+ * @param ?MonitoredObject $object
+ */
+ public function __construct($data, MonitoredObject $object = null)
+ {
+ $this->data = $data;
+ $this->object = $object;
+ $this->body = new HtmlElement('tbody');
+ }
+
+ /**
+ * Set the header to show
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ protected function setHeader($title)
+ {
+ $this->headerTitle = (string) $title;
+
+ return $this;
+ }
+
+ /**
+ * Add a new row to the body
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function addRow($name, $value)
+ {
+ $this->body->addHtml(new HtmlElement(
+ 'tr',
+ Attributes::create(['class' => "level-{$this->level}"]),
+ new HtmlElement('th', null, Html::wantHtml($name)),
+ new HtmlElement('td', null, Html::wantHtml($value))
+ ));
+ }
+
+ /**
+ * Render a variable
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderVar($name, $value)
+ {
+ if ($this->object !== null && $this->level === 0) {
+ list($name, $value, $group) = call_user_func($this->hookApplier, $name, $value);
+ if ($group !== null) {
+ $this->groups[$group][] = [$name, $value];
+ return;
+ }
+ }
+
+ $isArray = is_array($value);
+ if (! $isArray && $value instanceof \stdClass) {
+ $value = (array) $value;
+ $isArray = true;
+ }
+
+ switch (true) {
+ case $isArray && is_int(key($value)):
+ $this->renderArray($name, $value);
+ break;
+ case $isArray:
+ $this->renderObject($name, $value);
+ break;
+ default:
+ $this->renderScalar($name, $value);
+ }
+ }
+
+ /**
+ * Render an array
+ *
+ * @param mixed $name
+ * @param array $array
+ *
+ * @return void
+ */
+ protected function renderArray($name, array $array)
+ {
+ $numItems = count($array);
+ $name = (new HtmlDocument())->addHtml(
+ Html::wantHtml($name),
+ Text::create(' (Array)')
+ );
+
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($array);
+ foreach ($array as $key => $value) {
+ $this->renderVar("[$key]", $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render an object (associative array)
+ *
+ * @param mixed $name
+ * @param array $object
+ *
+ * @return void
+ */
+ protected function renderObject($name, array $object)
+ {
+ $numItems = count($object);
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($object);
+ foreach ($object as $key => $value) {
+ $this->renderVar($key, $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render a scalar
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderScalar($name, $value)
+ {
+ if ($value === '') {
+ $value = new HtmlElement('span', Attributes::create(['class' => 'empty']), Text::create(t('empty string')));
+ }
+
+ $this->addRow($name, $value);
+ }
+
+ /**
+ * Render a group
+ *
+ * @param string $name
+ * @param iterable $entries
+ *
+ * @return void
+ */
+ protected function renderGroup($name, $entries)
+ {
+ $table = new self($entries);
+
+ $wrapper = $this->getWrapper();
+ if ($wrapper === null) {
+ $wrapper = new HtmlDocument();
+ $wrapper->addHtml($this);
+ $this->prependWrapper($wrapper);
+ }
+
+ $wrapper->addHtml($table->setHeader($name));
+ }
+
+ protected function assemble()
+ {
+ if ($this->object !== null) {
+ $this->hookApplier = CustomVarRendererHook::prepareForObject($this->object);
+ }
+
+ if ($this->headerTitle !== null) {
+ $this->getAttributes()
+ ->add('class', 'collapsible')
+ ->add('data-visible-height', 100)
+ ->add('data-toggle-element', 'thead')
+ ->add(
+ 'id',
+ preg_replace('/\s+/', '-', strtolower($this->headerTitle)) . '-customvars'
+ );
+
+ $this->addHtml(new HtmlElement('thead', null, new HtmlElement(
+ 'tr',
+ null,
+ new HtmlElement(
+ 'th',
+ Attributes::create(['colspan' => 2]),
+ new HtmlElement(
+ 'span',
+ null,
+ new Icon('angle-right'),
+ new Icon('angle-down')
+ ),
+ Text::create($this->headerTitle)
+ )
+ )));
+ }
+
+ if (is_array($this->data)) {
+ ksort($this->data);
+ }
+
+ foreach ($this->data as $name => $value) {
+ $this->renderVar($name, $value);
+ }
+
+ $this->addHtml($this->body);
+
+ // Hooks can return objects as replacement for keys, hence a generator is needed for group entries
+ $genGenerator = function ($entries) {
+ foreach ($entries as list($key, $value)) {
+ yield $key => $value;
+ }
+ };
+
+ foreach ($this->groups as $group => $entries) {
+ $this->renderGroup($group, $genGenerator($entries));
+ }
+ }
+}
diff --git a/modules/monitoring/public/css/module.less b/modules/monitoring/public/css/module.less
index 1eb9ea6b5..82cdf83bb 100644
--- a/modules/monitoring/public/css/module.less
+++ b/modules/monitoring/public/css/module.less
@@ -716,6 +716,71 @@ form.instance-features span.description, form.object-features span.description {
}
}
+/* Object customvars */
+.custom-var-table {
+ .level-1 th {
+ padding-left: .5em;
+ }
+
+ .level-2 th {
+ padding-left: 1em;
+ }
+
+ .level-3 th {
+ padding-left: 1.5em;
+ }
+
+ .level-4 th {
+ padding-left: 2em;
+ }
+
+ .level-5 th {
+ padding-left: 2.5em;
+ }
+
+ .level-6 th {
+ padding-left: 3em;
+ }
+
+ .empty {
+ color: @gray-semilight;
+ }
+
+ thead th {
+ padding-left: 0;
+ text-align: left;
+ font-weight: bold;
+ font-size: 1.167em;
+
+ > span {
+ :nth-child(1),
+ :nth-child(2) {
+ display: none;
+ }
+ }
+ }
+
+ &.can-collapse thead th > span {
+ :nth-child(1) {
+ display: none;
+ }
+
+ :nth-child(2) {
+ display: inline-block;
+ }
+ }
+
+ &.collapsed thead th > span {
+ :nth-child(1) {
+ display: inline-block;
+ }
+
+ :nth-child(2) {
+ display: none;
+ }
+ }
+}
+
//p.pluginoutput {
// width: 100%;
// white-space: pre-wrap;