diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php index 1844486cb..4710994b4 100644 --- a/library/Icinga/Application/ApplicationBootstrap.php +++ b/library/Icinga/Application/ApplicationBootstrap.php @@ -6,6 +6,7 @@ namespace Icinga\Application; use ErrorException; use Exception; +use LogicException; use Icinga\Application\Modules\Manager as ModuleManager; use Icinga\Data\ResourceFactory; use Icinga\Exception\ConfigurationError; @@ -292,11 +293,25 @@ abstract class ApplicationBootstrap * * This is usually /public for Web and EmbeddedWeb and /bin for the CLI * - * @return string + * @return string + * + * @throws LogicException If the base directory can not be detected */ public function getBootstrapDirectory() { - return dirname(realpath($_SERVER['SCRIPT_FILENAME'])); + $script = $_SERVER['SCRIPT_FILENAME']; + $canonical = realpath($script); + if ($canonical !== false) { + $dir = dirname($canonical); + } elseif (substr($script, -14) === '/webrouter.php') { + // If Icinga Web 2 is served using PHP's built-in webserver with our webrouter.php script, the $_SERVER + // variable SCRIPT_FILENAME is set to DOCUMENT_ROOT/webrouter.php which is not a valid path to + // realpath but DOCUMENT_ROOT here still is the bootstrapping directory + $dir = dirname($script); + } else { + throw new LogicException('Can\'t detected base directory'); + } + return $dir; } /** @@ -444,6 +459,7 @@ abstract class ApplicationBootstrap return false; // Continue with the normal error handler } switch($errno) { + case E_NOTICE: case E_WARNING: case E_STRICT: throw new ErrorException($errstr, 0, $errno, $errfile, $errline); diff --git a/library/Icinga/Application/EmbeddedWeb.php b/library/Icinga/Application/EmbeddedWeb.php index 7ba2ba73e..23cb365f0 100644 --- a/library/Icinga/Application/EmbeddedWeb.php +++ b/library/Icinga/Application/EmbeddedWeb.php @@ -33,7 +33,6 @@ class EmbeddedWeb extends ApplicationBootstrap ->setupErrorHandling() ->setupTimezone() ->setupModuleManager() - ->loadCoreModules() ->loadEnabledModules(); } } diff --git a/library/Icinga/Data/Filter/Filter.php b/library/Icinga/Data/Filter/Filter.php index 99b0290f1..a0a56e4bb 100644 --- a/library/Icinga/Data/Filter/Filter.php +++ b/library/Icinga/Data/Filter/Filter.php @@ -22,7 +22,17 @@ abstract class Filter return $this; } - abstract function toQueryString(); + abstract public function isExpression(); + + abstract public function isChain(); + + abstract public function isEmpty(); + + abstract public function toQueryString(); + + abstract public function andFilter(Filter $filter); + + abstract public function orFilter(Filter $filter); public function getUrlParams() { @@ -96,7 +106,7 @@ abstract class Filter public function getParentId() { - if ($self->isRootNode()) { + if ($this->isRootNode()) { throw new ProgrammingError('Filter root nodes have no parent'); } return substr($this->id, 0, strrpos($this->id, '-')); diff --git a/library/Icinga/Data/Filter/FilterAnd.php b/library/Icinga/Data/Filter/FilterAnd.php index 899f488ce..8fd2f2411 100644 --- a/library/Icinga/Data/Filter/FilterAnd.php +++ b/library/Icinga/Data/Filter/FilterAnd.php @@ -30,4 +30,14 @@ class FilterAnd extends FilterChain } return true; } + + public function andFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($this, $filter); + } } diff --git a/library/Icinga/Data/Filter/FilterChain.php b/library/Icinga/Data/Filter/FilterChain.php index 9d32252fe..76c56a7af 100644 --- a/library/Icinga/Data/Filter/FilterChain.php +++ b/library/Icinga/Data/Filter/FilterChain.php @@ -166,6 +166,16 @@ abstract class FilterChain extends Filter } } + public function isExpression() + { + return false; + } + + public function isChain() + { + return true; + } + public function isEmpty() { return empty($this->filters); @@ -174,7 +184,8 @@ abstract class FilterChain extends Filter public function addFilter(Filter $filter) { $this->filters[] = $filter; - $filter->setId($this->getId() . '-' . (count($this->filters))); + $filter->setId($this->getId() . '-' . $this->count()); + return $this; } public function &filters() @@ -182,6 +193,11 @@ abstract class FilterChain extends Filter return $this->filters; } + public function count() + { + return count($this->filters); + } + public function __clone() { foreach ($this->filters as & $filter) { diff --git a/library/Icinga/Data/Filter/FilterExpression.php b/library/Icinga/Data/Filter/FilterExpression.php index 8378b5ae3..f87f3602f 100644 --- a/library/Icinga/Data/Filter/FilterExpression.php +++ b/library/Icinga/Data/Filter/FilterExpression.php @@ -19,6 +19,16 @@ class FilterExpression extends Filter $this->expression = $expression; } + public function isExpression() + { + return true; + } + + public function isChain() + { + return false; + } + public function isEmpty() { return false; @@ -97,4 +107,14 @@ class FilterExpression extends Filter return (bool) preg_match($pattern, $row->{$this->column}); } } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($this, $filter); + } } diff --git a/library/Icinga/Data/Filter/FilterNot.php b/library/Icinga/Data/Filter/FilterNot.php index a49a7ab3d..5e63691f1 100644 --- a/library/Icinga/Data/Filter/FilterNot.php +++ b/library/Icinga/Data/Filter/FilterNot.php @@ -22,6 +22,16 @@ class FilterNot extends FilterChain return true; } + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($filter); + } + public function toQueryString() { $parts = array(); diff --git a/library/Icinga/Data/Filter/FilterOr.php b/library/Icinga/Data/Filter/FilterOr.php index 427c17fd2..8291d32dc 100644 --- a/library/Icinga/Data/Filter/FilterOr.php +++ b/library/Icinga/Data/Filter/FilterOr.php @@ -19,4 +19,22 @@ class FilterOr extends FilterChain } return false; } + + public function setOperatorName($name) + { + if ($this->count() > 1 && $name === 'NOT') { + return Filter::not(clone $this); + } + return parent::setOperatorName($name); + } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return $this->addFilter($filter); + } } diff --git a/library/Icinga/Web/Request.php b/library/Icinga/Web/Request.php index 8b09f68fb..ae957f6a9 100644 --- a/library/Icinga/Web/Request.php +++ b/library/Icinga/Web/Request.php @@ -19,6 +19,16 @@ class Request extends Zend_Controller_Request_Http */ private $user; + private $url; + + public function getUrl() + { + if ($this->url === null) { + $this->url = Url::fromRequest($this); + } + return $this->url; + } + /** * Setter for user * diff --git a/library/Icinga/Web/Widget/AbstractWidget.php b/library/Icinga/Web/Widget/AbstractWidget.php index fe4d2434d..5b9d8876b 100644 --- a/library/Icinga/Web/Widget/AbstractWidget.php +++ b/library/Icinga/Web/Widget/AbstractWidget.php @@ -34,7 +34,7 @@ abstract class AbstractWidget protected static $view; // TODO: Should we kick this? - protected $properties; + protected $properties = array(); /** * Getter for widget properties diff --git a/library/Icinga/Web/Widget/FilterEditor.php b/library/Icinga/Web/Widget/FilterEditor.php index f91c44669..3721e488c 100644 --- a/library/Icinga/Web/Widget/FilterEditor.php +++ b/library/Icinga/Web/Widget/FilterEditor.php @@ -8,7 +8,9 @@ use Icinga\Data\Filter\Filter; use Icinga\Data\Filter\FilterExpression; use Icinga\Data\Filter\FilterChain; use Icinga\Web\Url; +use Icinga\Application\Icinga; use Icinga\Exception\ProgrammingError; +use Exception; /** * Filter @@ -24,6 +26,16 @@ class FilterEditor extends AbstractWidget protected $query; + protected $url; + + protected $addTo; + + protected $cachedColumnSelect; + + protected $preserveParams = array(); + + protected $ignoreParams = array(); + /** * @var string */ @@ -36,10 +48,200 @@ class FilterEditor extends AbstractWidget */ public function __construct($props) { - $this->filter = $props['filter']; - if (array_key_exists('query', $props)) { - $this->query = $props['query']; + if (array_key_exists('filter', $props)) { + $this->setFilter($props['filter']); } + if (array_key_exists('query', $props)) { + $this->setQuery($props['query']); + } + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + return $this; + } + + public function getFilter() + { + if ($this->filter === null) { + $this->filter = Filter::fromQueryString((string) $this->url()->getParams()); + } + return $this->filter; + } + + public function setUrl($url) + { + $this->url = $url; + return $this; + } + + protected function url() + { + if ($this->url === null) { + $this->url = Url::fromRequest(); + } + return $this->url; + } + + public function setQuery($query) + { + $this->query = $query; + return $this; + } + + public function ignoreParams() + { + $this->ignoreParams = func_get_args(); + return $this; + } + + public function preserveParams() + { + $this->preserveParams = func_get_args(); + return $this; + } + + protected function redirectNow($url) + { + $response = Icinga::app()->getFrontController()->getResponse(); + $response->redirectAndExit($url); + } + + protected function mergeRootExpression($filter, $column, $sign, $expression) + { + $found = false; + if ($filter->isChain() && $filter->getOperatorName() === 'AND') { + foreach ($filter->filters() as $f) { + if ($f->isExpression() + && $f->getColumn() === $column + && $f->getSign() === $sign + ) { + $f->setExpression($expression); + $found = true; + break; + } + } + } elseif ($filter->isExpression()) { + if ($filter->getColumn() === $column && $filter->getSign() === $sign) { + $filter->setExpression($expression); + $found = true; + } + } + if (! $found) { + $filter = $filter->andFilter( + Filter::expression($column, $sign, $expression) + ); + } + return $filter; + } + + public function handleRequest($request) + { + $this->setUrl($request->getUrl()->without($this->ignoreParams)); + $params = $this->url()->getParams(); + + $preserve = array(); + foreach ($this->preserveParams as $key) { + if (null !== ($value = $params->shift($key))) { + $preserve[$key] = $value; + } + } + + $add = $params->shift('addFilter'); + $remove = $params->shift('removeFilter'); + $strip = $params->shift('stripFilter'); + $modify = $params->shift('modifyFilter'); + + + + $search = null; + if ($request->isPost()) { + $search = $request->getPost('q'); + } + + if ($search === null) { + $search = $params->shift('q'); + } + + $filter = $this->getFilter(); + + if ($search !== null) { + if (strpos($search, '=') === false) { + // TODO: Ask the view for (multiple) search columns + switch($request->getActionName()) { + case 'services': + $searchCol = 'service_description'; + break; + case 'hosts': + $searchCol = 'host_name'; + break; + case 'hostgroups': + $searchCol = 'hostgroup'; + break; + case 'servicegroups': + $searchCol = 'servicegroup'; + break; + default: + $searchCol = null; + } + + if ($searchCol === null) { + throw new Exception('Cannot search here'); + } + $filter = $this->mergeRootExpression($filter, $searchCol, '=', "*$search*"); + + } else { + list($k, $v) = preg_split('/=/', $search); + $filter = $this->mergeRootExpression($filter, $k, '=', $v); + } + + $url = $this->url()->setQueryString( + $filter->toQueryString() + )->addParams($preserve); + if ($modify) { + $url->getParams()->add('modifyFilter'); + } + $this->redirectNow($url); + } + + if ($remove) { + $redirect = $this->url(); + if ($filter->getById($remove)->isRootNode()) { + $redirect->setQueryString(''); + } else { + $filter->removeId($remove); + $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); + } + $this->redirectNow($redirect->addParams($preserve)); + } + + if ($strip) { + $redirect = $this->url(); + $filter->replaceById($strip, $filter->getById($strip . '-1')); + $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); + $this->redirectNow($redirect->addParams($preserve)); + } + + + if ($modify) { + if ($request->isPost()) { + if ($request->get('cancel') === 'Cancel') { + $this->redirectNow($this->url()->without('modifyFilter')); + } + + $filter = $this->applyChanges($request->getPost()); + $url = $this->url()->setQueryString($filter->toQueryString())->addParams($preserve); + $url->getParams()->add('modifyFilter'); + $this->redirectNow($url); + } + $this->url()->getParams()->add('modifyFilter'); + } + + if ($add) { + $this->addFilterToId($add); + } + return $this; } protected function select($name, $list, $selected, $attributes = null) @@ -50,9 +252,12 @@ class FilterEditor extends AbstractWidget } else { $attributes = $view->propertiesToString($attributes); } - $html = '' . "\n", + $view->escape($name), + $attributes + ); - asort($list); foreach ($list as $k => $v) { $active = ''; if ($k === $selected) { @@ -69,100 +274,169 @@ class FilterEditor extends AbstractWidget return $html; } - public function markIndex($idx) + protected function addFilterToId($id) + { + $this->addTo = $id; + return $this; + } + + protected function removeIndex($idx) { $this->selectedIdx = $idx; return $this; } - public function removeIndex($idx) + protected function removeLink(Filter $filter) { - $this->selectedIdx = $idx; - return $this; + return $this->view()->qlink( + '', + $this->url()->with('removeFilter', $filter->getId()), + null, + array( + 'title' => t('Click to remove this part of your filter'), + 'class' => 'icon-cancel' + ) + ); + } + + protected function addLink(Filter $filter) + { + return $this->view()->qlink( + '', + $this->url()->with('addFilter', $filter->getId()), + null, + array( + 'title' => t('Click to add another filter'), + 'class' => 'icon-plus' + ) + ); + } + + protected function stripLink(Filter $filter) + { + return $this->view()->qlink( + '', + $this->url()->with('stripFilter', $filter->getId()), + null, + array( + 'title' => t('Strip this filter'), + 'class' => 'icon-minus' + ) + ); + } + + protected function cancelLink() + { + return $this->view()->qlink( + '', + $this->url()->without('addFilter'), + null, + array( + 'title' => t('Cancel this operation'), + 'class' => 'icon-cancel' + ) + ); } protected function renderFilter($filter, $level = 0) { - $html = ''; - $url = Url::fromRequest(); - - $view = $this->view(); - $idx = $filter->getId(); - $markUrl = clone($url); - $markUrl->setParam('fIdx', $idx); - - $removeUrl = clone $url; - $removeUrl->setParam('removeFilter', $idx); - $removeLink = ' ' . $view->icon('cancel') . ''; - - /* - // Temporarilly removed, not implemented yet - $addUrl = clone($url); - $addUrl->setParam('addToId', $idx); - $addLink = ' ' . t('Operator') . ' (&, !, |)'; - $addLink .= ' ' . t('Expression') . ' (=, <, >, <=, >=)'; - */ - $selectedIndex = ($idx === $this->selectedIdx ? ' -<--' : ''); - $selectIndex = ' o'; + if ($level === 0 && $filter->isChain() && $filter->isEmpty()) { + return ''; + } if ($filter instanceof FilterChain) { - $parts = array(); - $i = 0; + return $this->renderFilterChain($filter, $level); + } elseif ($filter instanceof FilterExpression) { + return $this->renderFilterExpression($filter); + } else { + throw new ProgrammingError('Got a Filter being neither expression nor chain'); + } + } - foreach ($filter->filters() as $f) { - $i++; - $parts[] = $this->renderFilter($f, $level + 1); - } + protected function renderFilterChain(FilterChain $filter, $level) + { + $html = ' ' + . $this->selectOperator($filter) + . $this->removeLink($filter) + . ($filter->count() === 1 ? $this->stripLink($filter) : '') + . $this->addLink($filter); - $op = $this->select( - 'operator_' . $filter->getId(), - array( - 'OR' => 'OR', - 'AND' => 'AND', - 'NOT' => 'NOT' - ), - $filter->getOperatorName(), - array('style' => 'width: 5em') - ) . $removeLink; // Disabled: . ' ' . t('Add') . ': ' . $addLink; - $html .= ' '; - - if ($level === 0) { - $html .= $op; - if (! empty($parts)) { - $html .= ''; - } - } else { - $html .= $op . "\n"; - } + if ($filter->isEmpty() && ! $this->addTo) { return $html; } - if ($filter instanceof FilterExpression) { - $u = $url->without($filter->getColumn()); - } else { - throw new ProgrammingError('Got a Filter being neither expression nor chain'); + $parts = array(); + foreach ($filter->filters() as $f) { + $parts[] = '
  • ' . $this->renderFilter($f, $level + 1) . '
  • '; } - $value = $filter->getExpression(); + + if ($this->addTo && $this->addTo == $filter->getId()) { + $parts[] = '
  • ' . $this->renderNewFilter() .$this->cancelLink(). '
  • '; + } + + $class = $level === 0 ? ' class="datafilter"' : ''; + $html .= sprintf( + "\n%s\n", + $class, + implode("", $parts) + ); + return $html; + } + + protected function renderFilterExpression(FilterExpression $filter) + { + if ($this->addTo && $this->addTo === $filter->getId()) { + return + preg_replace( + '/ class="autosubmit"/', + ' class="autofocus"', + $this->selectOperator() + ) + . '' + ; + } else { + return $this->selectColumn($filter) + . $this->selectSign($filter) + . $this->text($filter) + . $this->removeLink($filter) + . $this->addLink($filter) + ; + + } + } + + protected function text(Filter $filter = null) + { + $value = $filter === null ? '' : $filter->getExpression(); if (is_array($value)) { $value = '(' . implode('|', $value) . ')'; } - $html .= $this->selectColumn($filter) . ' ' - . $this->selectSign($filter) - . ' ' . $removeLink; + return sprintf( + '', + $this->elementId('value', $filter), + $value + ); + } - return $html; + protected function renderNewFilter() + { + $html = $this->selectColumn() + . $this->selectSign() + . $this->text(); + + return preg_replace( + '/ class="autosubmit"/', + '', + $html + ); } protected function arrayForSelect($array) @@ -179,9 +453,33 @@ class FilterEditor extends AbstractWidget return $res; } - protected function selectSign($filter) + protected function elementId($prefix, Filter $filter = null) + { + if ($filter === null) { + return $prefix . '_new_' . ($this->addTo ?: '0'); + } else { + return $prefix . '_' . $filter->getId(); + } + } + + protected function selectOperator(Filter $filter = null) + { + $ops = array( + 'AND' => 'AND', + 'OR' => 'OR', + 'NOT' => 'NOT' + ); + + return $this->select( + $this->elementId('operator', $filter), + $ops, + $filter === null ? null : $filter->getOperatorName(), + array('style' => 'width: 5em') + ); + } + + protected function selectSign(Filter $filter = null) { - $name = 'sign_' . $filter->getId(); $signs = array( '=' => '=', '!=' => '!=', @@ -192,17 +490,30 @@ class FilterEditor extends AbstractWidget ); return $this->select( - $name, + $this->elementId('sign', $filter), $signs, - $filter->getSign(), + $filter === null ? null : $filter->getSign(), array('style' => 'width: 4em') ); } - protected function selectColumn($filter) + + protected function selectColumn(Filter $filter = null) { - $name = 'column_' . $filter->getId(); - $cols = $this->arrayForSelect($this->query->getColumns()); - $active = $filter->getColumn(); + $active = $filter === null ? null : $filter->getColumn(); + + if ($this->query === null) { + return sprintf( + '', + $this->elementId('column', $filter), + $this->view()->escape($active) // Escape attribute? + ); + } + + if ($this->cachedColumnSelect === null) { + $this->cachedColumnSelect = $this->arrayForSelect($this->query->getColumns()); + asort($this->cachedColumnSelect); + } + $cols = $this->cachedColumnSelect; $seen = false; foreach ($cols as $k => & $v) { $v = str_replace('_', ' ', ucfirst($v)); @@ -215,32 +526,169 @@ class FilterEditor extends AbstractWidget $cols[$active] = str_replace('_', ' ', ucfirst(ltrim($active, '_'))); } - if ($this->query === null) { - return sprintf( - '', - $name, - $filter->getColumn() - ); - } else { - return $this->select( - $name, - $cols, - $active - ); + return $this->select($this->elementId('column', $filter), $cols, $active); + } + + protected function applyChanges($changes) + { + $filter = $this->filter; + $pairs = array(); + $addTo = null; + $add = array(); + foreach ($changes as $k => $v) { + if (preg_match('/^(column|value|sign|operator)((?:_new)?)_([\d-]+)$/', $k, $m)) { + if ($m[2] === '_new') { + if ($addTo !== null && $addTo !== $m[3]) { + throw new \Exception('F...U'); + } + $addTo = $m[3]; + $add[$m[1]] = $v; + } else { + $pairs[$m[3]][$m[1]] = $v; + } + } } + + $operators = array(); + foreach ($pairs as $id => $fs) { + if (array_key_exists('operator', $fs)) { + $operators[$id] = $fs['operator']; + } else { + $f = $filter->getById($id); + $f->setColumn($fs['column']); + if ($f->getSign() !== $fs['sign']) { + if ($f->isRootNode()) { + $filter = $f->setSign($fs['sign']); + } else { + $filter->replaceById($id, $f->setSign($fs['sign'])); + } + } + $f->setExpression($fs['value']); + } + } + + krsort($operators, version_compare(PHP_VERSION, '5.4.0') >= 0 ? SORT_NATURAL : SORT_REGULAR); + foreach ($operators as $id => $operator) { + $f = $filter->getById($id); + if ($f->getOperatorName() !== $operator) { + if ($f->isRootNode()) { + $filter = $f->setOperatorName($operator); + } else { + $filter->replaceById($id, $f->setOperatorName($operator)); + } + } + } + + if ($addTo !== null) { + if ($addTo === '0') { + $filter = Filter::expression($add['column'], $add['sign'], $add['value']); + } else { + $parent = $filter->getById($addTo); + $f = Filter::expression($add['column'], $add['sign'], $add['value']); + if ($add['operator']) { + switch($add['operator']) { + case 'AND': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::matchAll(clone $parent, $f); + } else { + $filter = $filter->replaceById($addTo, Filter::matchAll(clone $parent, $f)); + } + } else { + $parent->addFilter(Filter::matchAll($f)); + } + break; + case 'OR': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::matchAny(clone $parent, $f); + } else { + $filter = $filter->replaceById($addTo, Filter::matchAny(clone $parent, $f)); + } + } else { + $parent->addFilter(Filter::matchAny($f)); + } + break; + case 'NOT': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::not(Filter::matchAll($parent, $f)); + } else { + $filter = $filter->replaceById($addTo, Filter::not(Filter::matchAll($parent, $f))); + } + } else { + $parent->addFilter(Filter::not($f)); + } + break; + } + } else { + $parent->addFilter($f); + } + } + } + + return $filter; + } + + public function renderSearch() + { + $html = '
    '; + + if ($this->filter->isEmpty()) { + $title = t('Filter this list'); + } else { + $title = t('Modify this filter'); + if (! $this->filter->isEmpty()) { + $title .= ': ' . $this->filter; + } + } + return $html + . '' + . '' + . ''; } public function render() { - return '

    ' - . t('Modify this filter') - . '

    ' + if (! $this->url()->getParam('modifyFilter')) { + return $this->renderSearch() . $this->shorten($this->filter, 50); + } + return $this->renderSearch() . '
    ' . '
    ' + . '' + . '
    ' + . '' + . '' + . '
    ' . '
    '; } + + protected function shorten($string, $length) + { + if (strlen($string) > $length) { + return substr($string, 0, $length) . '...'; + } + return $string; + } + + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return 'ERROR in FilterEditor: ' . $e->getMessage(); + } + } } diff --git a/library/Icinga/Web/Widget/Tabs.php b/library/Icinga/Web/Widget/Tabs.php index b2a34e0ec..72522282e 100644 --- a/library/Icinga/Web/Widget/Tabs.php +++ b/library/Icinga/Web/Widget/Tabs.php @@ -34,7 +34,7 @@ EOT; */ private $dropdownTpl = <<< 'EOT'