summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Web/Navigation
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icinga/Web/Navigation/ConfigMenu.php327
-rw-r--r--library/Icinga/Web/Navigation/DashboardPane.php84
-rw-r--r--library/Icinga/Web/Navigation/DropdownItem.php20
-rw-r--r--library/Icinga/Web/Navigation/Navigation.php572
-rw-r--r--library/Icinga/Web/Navigation/NavigationItem.php948
-rw-r--r--library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php139
-rw-r--r--library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php44
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php235
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php356
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php142
-rw-r--r--library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php186
-rw-r--r--library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php72
12 files changed, 3125 insertions, 0 deletions
diff --git a/library/Icinga/Web/Navigation/ConfigMenu.php b/library/Icinga/Web/Navigation/ConfigMenu.php
new file mode 100644
index 0000000..583bf42
--- /dev/null
+++ b/library/Icinga/Web/Navigation/ConfigMenu.php
@@ -0,0 +1,327 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use Icinga\Application\Hook\HealthHook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\MigrationManager;
+use Icinga\Authentication\Auth;
+use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBadge;
+use Throwable;
+
+class ConfigMenu extends BaseHtmlElement
+{
+ const STATE_OK = 'ok';
+ const STATE_CRITICAL = 'critical';
+ const STATE_WARNING = 'warning';
+ const STATE_PENDING = 'pending';
+ const STATE_UNKNOWN = 'unknown';
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'nav'];
+
+ protected $children;
+
+ protected $selected;
+
+ protected $state;
+
+ public function __construct()
+ {
+ $this->children = [
+ 'system' => [
+ 'title' => t('System'),
+ 'items' => [
+ 'about' => [
+ 'label' => t('About'),
+ 'url' => 'about'
+ ],
+ 'health' => [
+ 'label' => t('Health'),
+ 'url' => 'health',
+ ],
+ 'migrations' => [
+ 'label' => t('Migrations'),
+ 'url' => 'migrations',
+ ],
+ 'announcements' => [
+ 'label' => t('Announcements'),
+ 'url' => 'announcements'
+ ],
+ 'sessions' => [
+ 'label' => t('User Sessions'),
+ 'permission' => 'application/sessions',
+ 'url' => 'manage-user-devices'
+ ]
+ ]
+ ],
+ 'configuration' => [
+ 'title' => t('Configuration'),
+ 'permission' => 'config/*',
+ 'items' => [
+ 'application' => [
+ 'label' => t('Application'),
+ 'url' => 'config/general'
+ ],
+ 'authentication' => [
+ 'label' => t('Access Control'),
+ 'permission' => 'config/access-control/*',
+ 'url' => 'role/list'
+ ],
+ 'navigation' => [
+ 'label' => t('Shared Navigation'),
+ 'permission' => 'config/navigation',
+ 'url' => 'navigation/shared'
+ ],
+ 'modules' => [
+ 'label' => t('Modules'),
+ 'permission' => 'config/modules',
+ 'url' => 'config/modules'
+ ]
+ ]
+ ],
+ 'logout' => [
+ 'items' => [
+ 'logout' => [
+ 'label' => t('Logout'),
+ 'atts' => [
+ 'target' => '_self',
+ 'class' => 'nav-item-logout'
+ ],
+ 'url' => 'authentication/logout'
+ ]
+ ]
+ ]
+ ];
+
+ if (Logger::writesToFile()) {
+ $this->children['system']['items']['application_log'] = [
+ 'label' => t('Application Log'),
+ 'url' => 'list/applicationlog',
+ 'permission' => 'application/log'
+ ];
+ }
+ }
+
+ protected function assembleUserMenuItem(BaseHtmlElement $userMenuItem)
+ {
+ $username = Auth::getInstance()->getUser()->getUsername();
+
+ $userMenuItem->add(
+ new HtmlElement(
+ 'a',
+ Attributes::create(['href' => Url::fromPath('account')]),
+ new HtmlElement(
+ 'i',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($username[0])
+ ),
+ Text::create($username)
+ )
+ );
+
+ if (Icinga::app()->getRequest()->getUrl()->matches('account')) {
+ $userMenuItem->addAttributes(['class' => 'selected active']);
+ }
+ }
+
+ protected function assembleCogMenuItem($cogMenuItem)
+ {
+ $cogMenuItem->add([
+ HtmlElement::create(
+ 'button',
+ null,
+ [
+ new Icon('cog'),
+ $this->createHealthBadge() ?? $this->createMigrationBadge(),
+ ]
+ ),
+ $this->createLevel2Menu()
+ ]);
+ }
+
+ protected function assembleLevel2Nav(BaseHtmlElement $level2Nav)
+ {
+ $navContent = HtmlElement::create('div', ['class' => 'flyout-content']);
+ foreach ($this->children as $c) {
+ if (isset($c['permission']) && ! Auth::getInstance()->hasPermission($c['permission'])) {
+ continue;
+ }
+
+ if (isset($c['title'])) {
+ $navContent->add(HtmlElement::create(
+ 'h3',
+ null,
+ $c['title']
+ ));
+ }
+
+ $ul = HtmlElement::create('ul', ['class' => 'nav']);
+ foreach ($c['items'] as $key => $item) {
+ $ul->add($this->createLevel2MenuItem($item, $key));
+ }
+
+ $navContent->add($ul);
+ }
+
+ $level2Nav->add($navContent);
+ }
+
+ protected function getHealthCount()
+ {
+ $count = 0;
+ $worstState = null;
+ foreach (HealthHook::collectHealthData()->select() as $result) {
+ if ($worstState === null || $result->state > $worstState) {
+ $worstState = $result->state;
+ $count = 1;
+ } elseif ($worstState === $result->state) {
+ $count++;
+ }
+ }
+
+ switch ($worstState) {
+ case HealthHook::STATE_OK:
+ $count = 0;
+ break;
+ case HealthHook::STATE_WARNING:
+ $this->state = self::STATE_WARNING;
+ break;
+ case HealthHook::STATE_CRITICAL:
+ $this->state = self::STATE_CRITICAL;
+ break;
+ case HealthHook::STATE_UNKNOWN:
+ $this->state = self::STATE_UNKNOWN;
+ break;
+ }
+
+ return $count;
+ }
+
+ protected function isSelectedItem($item)
+ {
+ if ($item !== null && Icinga::app()->getRequest()->getUrl()->matches($item['url'])) {
+ $this->selected = $item;
+ return true;
+ }
+
+ return false;
+ }
+
+ protected function createHealthBadge(): ?StateBadge
+ {
+ $stateBadge = null;
+ if ($this->getHealthCount() > 0) {
+ $stateBadge = new StateBadge($this->getHealthCount(), $this->state);
+ $stateBadge->addAttributes(['class' => 'disabled']);
+ }
+
+ return $stateBadge;
+ }
+
+ protected function createMigrationBadge(): ?StateBadge
+ {
+ try {
+ $mm = MigrationManager::instance();
+ $count = $mm->count();
+ } catch (Throwable $e) {
+ Logger::error('Failed to load pending migrations: %s', $e);
+ $count = 0;
+ }
+
+ $stateBadge = null;
+ if ($count > 0) {
+ $stateBadge = new StateBadge($count, BadgeNavigationItemRenderer::STATE_PENDING);
+ $stateBadge->addAttributes(['class' => 'disabled']);
+ }
+
+ return $stateBadge;
+ }
+
+ protected function createLevel2Menu()
+ {
+ $level2Nav = HtmlElement::create(
+ 'div',
+ Attributes::create(['class' => 'nav-level-1 flyout'])
+ );
+
+ $this->assembleLevel2Nav($level2Nav);
+
+ return $level2Nav;
+ }
+
+ protected function createLevel2MenuItem($item, $key)
+ {
+ if (isset($item['permission']) && ! Auth::getInstance()->hasPermission($item['permission'])) {
+ return null;
+ }
+
+ $stateBadge = null;
+ $class = null;
+ if ($key === 'health') {
+ $class = 'badge-nav-item';
+ $stateBadge = $this->createHealthBadge();
+ } elseif ($key === 'migrations') {
+ $class = 'badge-nav-item';
+ $stateBadge = $this->createMigrationBadge();
+ }
+
+ $li = HtmlElement::create(
+ 'li',
+ $item['atts'] ?? [],
+ [
+ HtmlElement::create(
+ 'a',
+ Attributes::create(['href' => Url::fromPath($item['url'])]),
+ [
+ $item['label'],
+ $stateBadge ?? ''
+ ]
+ ),
+ ]
+ );
+ $li->addAttributes(['class' => $class]);
+
+ if ($this->isSelectedItem($item)) {
+ $li->addAttributes(['class' => 'selected']);
+ }
+
+ return $li;
+ }
+
+ protected function createUserMenuItem()
+ {
+ $userMenuItem = HtmlElement::create('li', ['class' => 'user-nav-item']);
+
+ $this->assembleUserMenuItem($userMenuItem);
+
+ return $userMenuItem;
+ }
+
+ protected function createCogMenuItem()
+ {
+ $cogMenuItem = HtmlElement::create('li', ['class' => 'config-nav-item']);
+
+ $this->assembleCogMenuItem($cogMenuItem);
+
+ return $cogMenuItem;
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createUserMenuItem(),
+ $this->createCogMenuItem()
+ ]);
+ }
+}
diff --git a/library/Icinga/Web/Navigation/DashboardPane.php b/library/Icinga/Web/Navigation/DashboardPane.php
new file mode 100644
index 0000000..71b3215
--- /dev/null
+++ b/library/Icinga/Web/Navigation/DashboardPane.php
@@ -0,0 +1,84 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use Icinga\Web\Url;
+
+/**
+ * A dashboard pane
+ */
+class DashboardPane extends NavigationItem
+{
+ /**
+ * This pane's dashlets
+ *
+ * @var array
+ */
+ protected $dashlets;
+
+ protected $disabled;
+
+ /**
+ * Set this pane's dashlets
+ *
+ * @param array $dashlets
+ *
+ * @return $this
+ */
+ public function setDashlets(array $dashlets)
+ {
+ $this->dashlets = $dashlets;
+ return $this;
+ }
+
+ /**
+ * Return this pane's dashlets
+ *
+ * @param bool $ordered Whether to order the dashlets first
+ *
+ * @return array
+ */
+ public function getDashlets($ordered = true)
+ {
+ if ($this->dashlets === null) {
+ return array();
+ }
+
+ if ($ordered) {
+ $dashlets = $this->dashlets;
+ ksort($dashlets);
+ return $dashlets;
+ }
+
+ return $this->dashlets;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function init()
+ {
+ $this->setUrl(Url::fromPath('dashboard', array('pane' => $this->getName())));
+ }
+
+ /**
+ * Set disabled state for pane
+ *
+ * @param bool $disabled
+ */
+ public function setDisabled($disabled = true)
+ {
+ $this->disabled = (bool) $disabled;
+ }
+
+ /**
+ * Get disabled state for pane
+ *
+ * @return bool
+ */
+ public function getDisabled()
+ {
+ return $this->disabled;
+ }
+}
diff --git a/library/Icinga/Web/Navigation/DropdownItem.php b/library/Icinga/Web/Navigation/DropdownItem.php
new file mode 100644
index 0000000..2342b96
--- /dev/null
+++ b/library/Icinga/Web/Navigation/DropdownItem.php
@@ -0,0 +1,20 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+/**
+ * Dropdown navigation item
+ *
+ * @see \Icinga\Web\Navigation\Navigation For a usage example.
+ */
+class DropdownItem extends NavigationItem
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function init()
+ {
+ $this->children->setLayout(Navigation::LAYOUT_DROPDOWN);
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Navigation.php b/library/Icinga/Web/Navigation/Navigation.php
new file mode 100644
index 0000000..4343c3c
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Navigation.php
@@ -0,0 +1,572 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use ArrayAccess;
+use ArrayIterator;
+use Exception;
+use Countable;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Traversable;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Authentication\Auth;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Util\StringHelper;
+use Icinga\Web\Navigation\Renderer\RecursiveNavigationRenderer;
+
+/**
+ * Container for navigation items
+ */
+class Navigation implements ArrayAccess, Countable, IteratorAggregate
+{
+ /**
+ * The class namespace where to locate navigation type classes
+ *
+ * @var string
+ */
+ const NAVIGATION_NS = 'Web\\Navigation';
+
+ /**
+ * Flag for dropdown layout
+ *
+ * @var int
+ */
+ const LAYOUT_DROPDOWN = 1;
+
+ /**
+ * Flag for tabs layout
+ *
+ * @var int
+ */
+ const LAYOUT_TABS = 2;
+
+ /**
+ * Known navigation types
+ *
+ * @var array
+ */
+ protected static $types;
+
+ /**
+ * This navigation's items
+ *
+ * @var NavigationItem[]
+ */
+ protected $items = array();
+
+ /**
+ * This navigation's layout
+ *
+ * @var int
+ */
+ protected $layout;
+
+ public function offsetExists($offset): bool
+ {
+ return isset($this->items[$offset]);
+ }
+
+ public function offsetGet($offset): ?NavigationItem
+ {
+ return $this->items[$offset] ?? null;
+ }
+
+ public function offsetSet($offset, $value): void
+ {
+ $this->items[$offset] = $value;
+ }
+
+ public function offsetUnset($offset): void
+ {
+ unset($this->items[$offset]);
+ }
+
+ public function count(): int
+ {
+ return count($this->items);
+ }
+
+ public function getIterator(): Traversable
+ {
+ $this->order();
+ return new ArrayIterator($this->items);
+ }
+
+ /**
+ * Create and return a new navigation item for the given configuration
+ *
+ * @param string $name
+ * @param array|ConfigObject $properties
+ *
+ * @return NavigationItem
+ *
+ * @throws InvalidArgumentException If the $properties argument is neither an array nor a ConfigObject
+ */
+ public function createItem($name, $properties)
+ {
+ if ($properties instanceof ConfigObject) {
+ $properties = $properties->toArray();
+ } elseif (! is_array($properties)) {
+ throw new InvalidArgumentException('Argument $properties must be of type array or ConfigObject');
+ }
+
+ $itemType = isset($properties['type']) ? StringHelper::cname($properties['type'], '-') : 'NavigationItem';
+ if (! empty(static::$types) && isset(static::$types[$itemType])) {
+ return new static::$types[$itemType]($name, $properties);
+ }
+
+ $item = null;
+ $classPath = null;
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ $classPath = 'Icinga\\Module\\'
+ . ucfirst($module->getName())
+ . '\\'
+ . static::NAVIGATION_NS
+ . '\\'
+ . $itemType;
+ if (class_exists($classPath)) {
+ $item = new $classPath($name, $properties);
+ break;
+ }
+ }
+
+ if ($item === null) {
+ $classPath = 'Icinga\\' . static::NAVIGATION_NS . '\\' . $itemType;
+ if (class_exists($classPath)) {
+ $item = new $classPath($name, $properties);
+ }
+ }
+
+ if ($item === null) {
+ if ($itemType !== 'MenuItem') {
+ Logger::debug(
+ 'Failed to find custom navigation item class %s for item %s. Using base class NavigationItem now',
+ $itemType,
+ $name
+ );
+ }
+
+ $item = new NavigationItem($name, $properties);
+ static::$types[$itemType] = 'Icinga\\Web\\Navigation\\NavigationItem';
+ } elseif (! $item instanceof NavigationItem) {
+ throw new ProgrammingError('Class %s must inherit from NavigationItem', $classPath);
+ } else {
+ static::$types[$itemType] = $classPath;
+ }
+
+ return $item;
+ }
+
+ /**
+ * Add a navigation item
+ *
+ * If you do not pass an instance of NavigationItem, this will only add the item
+ * if it does not require a permission or the current user has the permission.
+ *
+ * @param string|NavigationItem $name The name of the item or an instance of NavigationItem
+ * @param array $properties The properties of the item to add (Ignored if $name is not a string)
+ *
+ * @return bool Whether the item was added or not
+ *
+ * @throws InvalidArgumentException In case $name is neither a string nor an instance of NavigationItem
+ */
+ public function addItem($name, array $properties = array())
+ {
+ if (is_string($name)) {
+ if (isset($properties['permission'])) {
+ if (! Auth::getInstance()->hasPermission($properties['permission'])) {
+ return false;
+ }
+
+ unset($properties['permission']);
+ }
+
+ $item = $this->createItem($name, $properties);
+ } elseif (! $name instanceof NavigationItem) {
+ throw new InvalidArgumentException('Argument $name must be of type string or NavigationItem');
+ } else {
+ $item = $name;
+ }
+
+ $this->items[$item->getName()] = $item;
+ return true;
+ }
+
+ /**
+ * Return the item with the given name
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return NavigationItem|mixed
+ */
+ public function getItem($name, $default = null)
+ {
+ return isset($this->items[$name]) ? $this->items[$name] : $default;
+ }
+
+ /**
+ * Return the currently active item or the first one if none is active
+ *
+ * @return NavigationItem
+ */
+ public function getActiveItem()
+ {
+ foreach ($this->items as $item) {
+ if ($item->getActive()) {
+ return $item;
+ }
+ }
+
+ $firstItem = reset($this->items);
+ return $firstItem ? $firstItem->setActive() : null;
+ }
+
+ /**
+ * Return this navigation's items
+ *
+ * @return array
+ */
+ public function getItems()
+ {
+ return $this->items;
+ }
+
+ /**
+ * Return whether this navigation is empty
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return empty($this->items);
+ }
+
+ /**
+ * Return whether this navigation has any renderable items
+ *
+ * @return bool
+ */
+ public function hasRenderableItems()
+ {
+ foreach ($this->getItems() as $item) {
+ if ($item->shouldRender()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return this navigation's layout
+ *
+ * @return int
+ */
+ public function getLayout()
+ {
+ return $this->layout;
+ }
+
+ /**
+ * Set this navigation's layout
+ *
+ * @param int $layout
+ *
+ * @return $this
+ */
+ public function setLayout($layout)
+ {
+ $this->layout = (int) $layout;
+ return $this;
+ }
+
+ /**
+ * Create and return the renderer for this navigation
+ *
+ * @return RecursiveNavigationRenderer
+ */
+ public function getRenderer()
+ {
+ return new RecursiveNavigationRenderer($this);
+ }
+
+ /**
+ * Return this navigation rendered to HTML
+ *
+ * @return string
+ */
+ public function render()
+ {
+ return $this->getRenderer()->render();
+ }
+
+ /**
+ * Order this navigation's items
+ *
+ * @return $this
+ */
+ public function order()
+ {
+ uasort($this->items, array($this, 'compareItems'));
+ foreach ($this->items as $item) {
+ if ($item->hasChildren()) {
+ $item->getChildren()->order();
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether the first item is less than, more than or equal to the second one
+ *
+ * @param NavigationItem $a
+ * @param NavigationItem $b
+ *
+ * @return int
+ */
+ protected function compareItems(NavigationItem $a, NavigationItem $b)
+ {
+ if ($a->getPriority() === $b->getPriority()) {
+ return strcasecmp($a->getLabel(), $b->getLabel());
+ }
+
+ return $a->getPriority() > $b->getPriority() ? 1 : -1;
+ }
+
+ /**
+ * Try to find and return a item with the given or a similar name
+ *
+ * @param string $name
+ *
+ * @return ?NavigationItem
+ */
+ public function findItem($name)
+ {
+ $item = $this->getItem($name);
+ if ($item !== null) {
+ return $item;
+ }
+
+ $loweredName = strtolower($name);
+ foreach ($this->getItems() as $item) {
+ if (strtolower($item->getName()) === $loweredName) {
+ return $item;
+ }
+ }
+ }
+
+ /**
+ * Merge this navigation with the given one
+ *
+ * Any duplicate items of this navigation will be overwritten by the given navigation's items.
+ *
+ * @param Navigation $navigation
+ *
+ * @return $this
+ */
+ public function merge(Navigation $navigation)
+ {
+ foreach ($navigation as $item) {
+ /** @var $item NavigationItem */
+ if (($existingItem = $this->findItem($item->getName())) !== null) {
+ if ($existingItem->conflictsWith($item)) {
+ $name = $item->getName();
+ do {
+ if (preg_match('~_(\d+)$~', $name, $matches)) {
+ $name = preg_replace('~_\d+$~', (int) $matches[1] + 1, $name);
+ } else {
+ $name .= '_2';
+ }
+ } while ($this->getItem($name) !== null);
+
+ $this->addItem($item->setName($name));
+ } else {
+ $existingItem->merge($item);
+ }
+ } else {
+ $this->addItem($item);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Extend this navigation set with all additional items of the given type
+ *
+ * This will fetch navigation items from the following sources:
+ * * User Shareables
+ * * User Preferences
+ * * Modules
+ * Any existing entry will be overwritten by one that is coming later in order.
+ *
+ * @param string $type
+ *
+ * @return $this
+ */
+ public function load($type)
+ {
+ $user = Auth::getInstance()->getUser();
+ if ($type !== 'dashboard-pane') {
+ // Shareables
+ $this->merge(Icinga::app()->getSharedNavigation($type));
+
+ // User Preferences
+ $this->merge($user->getNavigation($type));
+ }
+
+ // Modules
+ $moduleManager = Icinga::app()->getModuleManager();
+ foreach ($moduleManager->getLoadedModules() as $module) {
+ if ($user->can($moduleManager::MODULE_PERMISSION_NS . $module->getName())) {
+ if ($type === 'menu-item') {
+ $this->merge($module->getMenu());
+ } elseif ($type === 'dashboard-pane') {
+ $this->merge($module->getDashboard());
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the global navigation item type configuration
+ *
+ * @return array
+ */
+ public static function getItemTypeConfiguration()
+ {
+ $defaultItemTypes = array(
+ 'menu-item' => array(
+ 'label' => t('Menu Entry'),
+ 'config' => 'menu'
+ )/*, // Disabled, until it is able to fully replace the old implementation
+ 'dashlet' => array(
+ 'label' => 'Dashlet',
+ 'config' => 'dashboard'
+ )*/
+ );
+
+ $moduleItemTypes = array();
+ $moduleManager = Icinga::app()->getModuleManager();
+ foreach ($moduleManager->getLoadedModules() as $module) {
+ if (Auth::getInstance()->hasPermission($moduleManager::MODULE_PERMISSION_NS . $module->getName())) {
+ foreach ($module->getNavigationItems() as $type => $options) {
+ if (! isset($moduleItemTypes[$type])) {
+ $moduleItemTypes[$type] = $options;
+ }
+ }
+ }
+ }
+
+ return array_merge($defaultItemTypes, $moduleItemTypes);
+ }
+
+ /**
+ * Create and return a new set of navigation items for the given configuration
+ *
+ * Note that this is supposed to be utilized for one dimensional structures
+ * only. Multi dimensional structures can be processed by fromArray().
+ *
+ * @param Traversable|array $config
+ *
+ * @return Navigation
+ *
+ * @throws InvalidArgumentException In case the given configuration is invalid
+ * @throws ConfigurationError In case a referenced parent does not exist
+ */
+ public static function fromConfig($config)
+ {
+ if (! is_array($config) && !$config instanceof Traversable) {
+ throw new InvalidArgumentException('Argument $config must be an array or a instance of Traversable');
+ }
+
+ $flattened = $orphans = $topLevel = array();
+ foreach ($config as $sectionName => $sectionConfig) {
+ $parentName = $sectionConfig->parent;
+ unset($sectionConfig->parent);
+
+ if (! $parentName) {
+ $topLevel[$sectionName] = $sectionConfig->toArray();
+ $flattened[$sectionName] = & $topLevel[$sectionName];
+ } elseif (isset($flattened[$parentName])) {
+ $flattened[$parentName]['children'][$sectionName] = $sectionConfig->toArray();
+ $flattened[$sectionName] = & $flattened[$parentName]['children'][$sectionName];
+ } else {
+ $orphans[$parentName][$sectionName] = $sectionConfig->toArray();
+ $flattened[$sectionName] = & $orphans[$parentName][$sectionName];
+ }
+ }
+
+ do {
+ $match = false;
+ foreach ($orphans as $parentName => $children) {
+ if (isset($flattened[$parentName])) {
+ if (isset($flattened[$parentName]['children'])) {
+ $flattened[$parentName]['children'] = array_merge(
+ $flattened[$parentName]['children'],
+ $children
+ );
+ } else {
+ $flattened[$parentName]['children'] = $children;
+ }
+
+ unset($orphans[$parentName]);
+ $match = true;
+ }
+ }
+ } while ($match && !empty($orphans));
+
+ if (! empty($orphans)) {
+ throw new ConfigurationError(
+ t(
+ 'Failed to fully parse navigation configuration. Ensure that'
+ . ' all referenced parents are existing navigation items: %s'
+ ),
+ join(', ', array_keys($orphans))
+ );
+ }
+
+ return static::fromArray($topLevel);
+ }
+
+ /**
+ * Create and return a new set of navigation items for the given array
+ *
+ * @param array $array
+ *
+ * @return Navigation
+ */
+ public static function fromArray(array $array)
+ {
+ $navigation = new static();
+ foreach ($array as $name => $properties) {
+ $navigation->addItem((string) $name, $properties);
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Return this navigation rendered to HTML
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Navigation/NavigationItem.php b/library/Icinga/Web/Navigation/NavigationItem.php
new file mode 100644
index 0000000..8aaf7b8
--- /dev/null
+++ b/library/Icinga/Web/Navigation/NavigationItem.php
@@ -0,0 +1,948 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation;
+
+use Exception;
+use Icinga\Authentication\Auth;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+use Icinga\Web\Url;
+use Traversable;
+
+/**
+ * A navigation item
+ */
+class NavigationItem implements IteratorAggregate
+{
+ /**
+ * Alternative markup element for items without a url
+ *
+ * @var string
+ */
+ const LINK_ALTERNATIVE = 'span';
+
+ /**
+ * The class namespace where to locate navigation type renderer classes
+ */
+ const RENDERER_NS = 'Web\\Navigation\\Renderer';
+
+ /**
+ * Whether this item is active
+ *
+ * @var bool
+ */
+ protected $active;
+
+ /**
+ * Whether this item is selected
+ *
+ * @var bool
+ */
+ protected $selected;
+
+ /**
+ * The CSS class used for the outer li element
+ *
+ * @var string
+ */
+ protected $cssClass;
+
+ /**
+ * This item's priority
+ *
+ * The priority defines when the item is rendered in relation to its parent's childs.
+ *
+ * @var int
+ */
+ protected $priority;
+
+ /**
+ * The attributes of this item's element
+ *
+ * @var array
+ */
+ protected $attributes;
+
+ /**
+ * This item's children
+ *
+ * @var Navigation
+ */
+ protected $children;
+
+ /**
+ * This item's icon
+ *
+ * @var string
+ */
+ protected $icon;
+
+ /**
+ * This item's name
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * This item's label
+ *
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * The item's description
+ *
+ * @var string
+ */
+ protected $description;
+
+ /**
+ * This item's parent
+ *
+ * @var NavigationItem
+ */
+ protected $parent;
+
+ /**
+ * This item's url
+ *
+ * @var Url
+ */
+ protected $url;
+
+ /**
+ * This item's url target
+ *
+ * @var string
+ */
+ protected $target;
+
+ /**
+ * Additional parameters for this item's url
+ *
+ * @var array
+ */
+ protected $urlParameters;
+
+ /**
+ * This item's renderer
+ *
+ * @var NavigationItemRenderer
+ */
+ protected $renderer;
+
+ /**
+ * Whether to render this item
+ *
+ * @var bool
+ */
+ protected $render;
+
+ /**
+ * Create a new NavigationItem
+ *
+ * @param string $name
+ * @param array $properties
+ */
+ public function __construct($name, array $properties = null)
+ {
+ $this->setName($name);
+ $this->children = new Navigation();
+
+ if (! empty($properties)) {
+ $this->setProperties($properties);
+ }
+
+ $this->init();
+ }
+
+ /**
+ * Initialize this NavigationItem
+ */
+ public function init()
+ {
+ }
+
+ /**
+ * @return Navigation
+ */
+ public function getIterator(): Traversable
+ {
+ return $this->getChildren();
+ }
+
+ /**
+ * Return whether this item is active
+ *
+ * @return bool
+ */
+ public function getActive()
+ {
+ if ($this->active === null) {
+ $this->active = false;
+ if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) {
+ $this->setActive();
+ } elseif ($this->hasChildren()) {
+ foreach ($this->getChildren() as $item) {
+ /** @var NavigationItem $item */
+ if ($item->getActive()) {
+ // Do nothing, a true active state is automatically passed to all parents
+ }
+ }
+ }
+ }
+
+ return $this->active;
+ }
+
+ /**
+ * Set whether this item is active
+ *
+ * If it's active and has a parent, the parent gets activated as well.
+ *
+ * @param bool $active
+ *
+ * @return $this
+ */
+ public function setActive($active = true)
+ {
+ $this->active = (bool) $active;
+ if ($this->active && $this->getParent() !== null) {
+ $this->getParent()->setActive();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether this item is selected
+ *
+ * @return bool
+ */
+ public function getSelected()
+ {
+ if ($this->selected === null) {
+ $this->active = false;
+ if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) {
+ $this->setSelected();
+ }
+ }
+
+ return $this->selected;
+ }
+
+ /**
+ * Set whether this item is active
+ *
+ * If it's active and has a parent, the parent gets activated as well.
+ *
+ * @param bool $selected
+ *
+ * @return $this
+ */
+ public function setSelected($selected = true)
+ {
+ $this->selected = (bool) $selected;
+
+ return $this;
+ }
+
+ /**
+ * Get the CSS class used for the outer li element
+ *
+ * @return string
+ */
+ public function getCssClass()
+ {
+ return $this->cssClass;
+ }
+
+ /**
+ * Set the CSS class to use for the outer li element
+ *
+ * @param string $class
+ *
+ * @return $this
+ */
+ public function setCssClass($class)
+ {
+ $this->cssClass = (string) $class;
+ return $this;
+ }
+
+ /**
+ * Return this item's priority
+ *
+ * @return int
+ */
+ public function getPriority()
+ {
+ return $this->priority !== null ? $this->priority : 100;
+ }
+
+ /**
+ * Set this item's priority
+ *
+ * @param int $priority
+ *
+ * @return $this
+ */
+ public function setPriority($priority)
+ {
+ $this->priority = (int) $priority;
+ return $this;
+ }
+
+ /**
+ * Return the value of the given element attribute
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getAttribute($name, $default = null)
+ {
+ $attributes = $this->getAttributes();
+ return array_key_exists($name, $attributes) ? $attributes[$name] : $default;
+ }
+
+ /**
+ * Set the value of the given element attribute
+ *
+ * @param string $name
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function setAttribute($name, $value)
+ {
+ $this->attributes[$name] = $value;
+ return $this;
+ }
+
+ /**
+ * Return the attributes of this item's element
+ *
+ * @return array
+ */
+ public function getAttributes()
+ {
+ return $this->attributes ?: array();
+ }
+
+ /**
+ * Set the attributes of this item's element
+ *
+ * @param array $attributes
+ *
+ * @return $this
+ */
+ public function setAttributes(array $attributes)
+ {
+ $this->attributes = $attributes;
+ return $this;
+ }
+
+ /**
+ * Add a child to this item
+ *
+ * If the child is active this item gets activated as well.
+ *
+ * @param NavigationItem $child
+ *
+ * @return $this
+ */
+ public function addChild(NavigationItem $child)
+ {
+ $this->getChildren()->addItem($child->setParent($this));
+ if ($child->getActive()) {
+ $this->setActive();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return this item's children
+ *
+ * @return Navigation
+ */
+ public function getChildren()
+ {
+ return $this->children;
+ }
+
+ /**
+ * Return whether this item has any children
+ *
+ * @return bool
+ */
+ public function hasChildren()
+ {
+ return ! $this->getChildren()->isEmpty();
+ }
+
+ /**
+ * Set this item's children
+ *
+ * @param array|Navigation $children
+ *
+ * @return $this
+ */
+ public function setChildren($children)
+ {
+ if (is_array($children)) {
+ $children = Navigation::fromArray($children);
+ } elseif (! $children instanceof Navigation) {
+ throw new InvalidArgumentException('Argument $children must be of type array or Navigation');
+ }
+
+ foreach ($children as $item) {
+ $item->setParent($this);
+ }
+
+ $this->children = $children;
+ return $this;
+ }
+
+ /**
+ * Return this item's icon
+ *
+ * @return string
+ */
+ public function getIcon()
+ {
+ return $this->icon;
+ }
+
+ /**
+ * Set this item's icon
+ *
+ * @param string $icon
+ *
+ * @return $this
+ */
+ public function setIcon($icon)
+ {
+ $this->icon = $icon;
+ return $this;
+ }
+
+ /**
+ * Return this item's name escaped with only ASCII chars and/or digits
+ *
+ * @return string
+ */
+ protected function getEscapedName()
+ {
+ return preg_replace('~[^a-zA-Z0-9]~', '_', $this->getName());
+ }
+
+ /**
+ * Return a unique version of this item's name
+ *
+ * @return string
+ */
+ public function getUniqueName()
+ {
+ if ($this->getParent() === null) {
+ return 'navigation-' . $this->getEscapedName();
+ }
+
+ return $this->getParent()->getUniqueName() . '-' . $this->getEscapedName();
+ }
+
+ /**
+ * Return this item's name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set this item's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Set this item's parent
+ *
+ * @param NavigationItem $parent
+ *
+ * @return $this
+ */
+ public function setParent(NavigationItem $parent)
+ {
+ $this->parent = $parent;
+ return $this;
+ }
+
+ /**
+ * Return this item's parent
+ *
+ * @return NavigationItem
+ */
+ public function getParent()
+ {
+ return $this->parent;
+ }
+
+ /**
+ * Return this item's label
+ *
+ * @return string
+ */
+ public function getLabel()
+ {
+ return $this->label !== null ? $this->label : $this->getName();
+ }
+
+ /**
+ * Set this item's label
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setLabel($label)
+ {
+ $this->label = $label;
+ return $this;
+ }
+
+ /**
+ * Get the item's description
+ *
+ * @return string
+ */
+ public function getDescription()
+ {
+ return $this->description;
+ }
+
+ /**
+ * Set the item's description
+ *
+ * @param string $description
+ *
+ * @return $this
+ */
+ public function setDescription($description)
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ /**
+ * Set this item's url target
+ *
+ * @param string $target
+ *
+ * @return $this
+ */
+ public function setTarget($target)
+ {
+ $this->target = $target;
+ return $this;
+ }
+
+ /**
+ * Return this item's url target
+ *
+ * @return string
+ */
+ public function getTarget()
+ {
+ return $this->target;
+ }
+
+ /**
+ * Return this item's url
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ if ($this->url === null && $this->hasChildren()) {
+ $this->setUrl(Url::fromPath('navigation/dashboard', array('name' => strtolower($this->getName()))));
+ }
+
+ return $this->url;
+ }
+
+ /**
+ * Set this item's url
+ *
+ * @param Url|string $url
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the given url is neither of type
+ */
+ public function setUrl($url)
+ {
+ if (is_string($url)) {
+ $url = Url::fromPath($this->resolveMacros($url));
+ } elseif ($url instanceof Url) {
+ $url = Url::fromPath($this->resolveMacros($url->getAbsoluteUrl()));
+ } else {
+ throw new InvalidArgumentException('Argument $url must be of type string or Url');
+ }
+
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Return the value of the given url parameter
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getUrlParameter($name, $default = null)
+ {
+ $parameters = $this->getUrlParameters();
+ return isset($parameters[$name]) ? $parameters[$name] : $default;
+ }
+
+ /**
+ * Set the value of the given url parameter
+ *
+ * @param string $name
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function setUrlParameter($name, $value)
+ {
+ $this->urlParameters[$name] = $value;
+ return $this;
+ }
+
+ /**
+ * Return all additional parameters for this item's url
+ *
+ * @return array
+ */
+ public function getUrlParameters()
+ {
+ return $this->urlParameters ?: array();
+ }
+
+ /**
+ * Set additional parameters for this item's url
+ *
+ * @param array $urlParameters
+ *
+ * @return $this
+ */
+ public function setUrlParameters(array $urlParameters)
+ {
+ $this->urlParameters = $urlParameters;
+ return $this;
+ }
+
+ /**
+ * Set this item's properties
+ *
+ * Unknown properties (no matching setter) are considered as element attributes.
+ *
+ * @param array $properties
+ *
+ * @return $this
+ */
+ public function setProperties(array $properties)
+ {
+ foreach ($properties as $name => $value) {
+ $setter = 'set' . ucfirst($name);
+ if (method_exists($this, $setter)) {
+ $this->$setter($value);
+ } else {
+ $this->setAttribute($name, $value);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Merge this item with the given one
+ *
+ * @param NavigationItem $item
+ *
+ * @return $this
+ */
+ public function merge(NavigationItem $item)
+ {
+ if ($this->conflictsWith($item)) {
+ throw new ProgrammingError('Cannot merge, conflict detected.');
+ }
+
+ if ($this->priority === null) {
+ $priority = $item->getPriority();
+ if ($priority !== 100) {
+ $this->setPriority($priority);
+ }
+ }
+
+ if (! $this->getIcon()) {
+ $this->setIcon($item->getIcon());
+ }
+
+ if ($this->getLabel() === $this->getName() && $item->getLabel() !== $item->getName()) {
+ $this->setLabel($item->getLabel());
+ }
+
+ if ($this->target === null && ($target = $item->getTarget()) !== null) {
+ $this->setTarget($target);
+ }
+
+ if ($this->renderer === null) {
+ $renderer = $item->getRenderer();
+ if (get_class($renderer) !== 'NavigationItemRenderer') {
+ $this->setRenderer($renderer);
+ }
+ }
+
+ foreach ($item->getAttributes() as $name => $value) {
+ $this->setAttribute($name, $value);
+ }
+
+ foreach ($item->getUrlParameters() as $name => $value) {
+ $this->setUrlParameter($name, $value);
+ }
+
+ if ($item->hasChildren()) {
+ $this->getChildren()->merge($item->getChildren());
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return whether it's possible to merge this item with the given one
+ *
+ * @param NavigationItem $item
+ *
+ * @return bool
+ */
+ public function conflictsWith(NavigationItem $item)
+ {
+ if (! $item instanceof $this) {
+ return true;
+ }
+
+ if ($this->getUrl() === null || $item->getUrl() === null) {
+ return false;
+ }
+
+ return !$this->getUrl()->matches($item->getUrl());
+ }
+
+ /**
+ * Create and return the given renderer
+ *
+ * @param string|array $name
+ *
+ * @return NavigationItemRenderer
+ */
+ protected function createRenderer($name)
+ {
+ if (is_array($name)) {
+ $options = array_splice($name, 1);
+ $name = $name[0];
+ } else {
+ $options = array();
+ }
+
+ $renderer = null;
+ $classPath = null;
+ foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
+ $classPath = 'Icinga\\Module\\' . ucfirst($module->getName()) . '\\' . static::RENDERER_NS . '\\' . $name;
+ if (class_exists($classPath)) {
+ $renderer = new $classPath($options);
+ break;
+ }
+ }
+
+ if ($renderer === null) {
+ $classPath = 'Icinga\\' . static::RENDERER_NS . '\\' . $name;
+ if (class_exists($classPath)) {
+ $renderer = new $classPath($options);
+ }
+ }
+
+ if ($renderer === null) {
+ throw new ProgrammingError(
+ 'Cannot find renderer "%s" for navigation item "%s"',
+ $name,
+ $this->getName()
+ );
+ } elseif (! $renderer instanceof NavigationItemRenderer) {
+ throw new ProgrammingError('Class %s must inherit from NavigationItemRenderer', $classPath);
+ }
+
+ return $renderer;
+ }
+
+ /**
+ * Set this item's renderer
+ *
+ * @param string|array|NavigationItemRenderer $renderer
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the $renderer argument is neither a string nor a NavigationItemRenderer
+ */
+ public function setRenderer($renderer)
+ {
+ if (is_string($renderer) || is_array($renderer)) {
+ $renderer = $this->createRenderer($renderer);
+ } elseif (! $renderer instanceof NavigationItemRenderer) {
+ throw new InvalidArgumentException(
+ 'Argument $renderer must be of type string, array or NavigationItemRenderer'
+ );
+ }
+
+ $this->renderer = $renderer;
+ return $this;
+ }
+
+ /**
+ * Return this item's renderer
+ *
+ * @return NavigationItemRenderer
+ */
+ public function getRenderer()
+ {
+ if ($this->renderer === null) {
+ $this->setRenderer('NavigationItemRenderer');
+ }
+
+ return $this->renderer;
+ }
+
+ /**
+ * Set whether this item should be rendered
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setRender($state = true)
+ {
+ $this->render = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether this item should be rendered
+ *
+ * @return bool
+ */
+ public function getRender()
+ {
+ if ($this->render === null) {
+ return $this->getUrl() !== null;
+ }
+
+ return $this->render;
+ }
+
+ /**
+ * Return whether this item should be rendered
+ *
+ * Alias for NavigationItem::getRender().
+ *
+ * @return bool
+ */
+ public function shouldRender()
+ {
+ return $this->getRender();
+ }
+
+ /**
+ * Return this item rendered to HTML
+ *
+ * @return string
+ */
+ public function render()
+ {
+ try {
+ return $this->getRenderer()->setItem($this)->render();
+ } catch (Exception $e) {
+ Logger::error(
+ 'Could not invoke custom navigation item renderer. %s in %s:%d with message: %s',
+ get_class($e),
+ $e->getFile(),
+ $e->getLine(),
+ $e->getMessage()
+ );
+
+ $renderer = new NavigationItemRenderer();
+ return $renderer->render($this);
+ }
+ }
+
+ /**
+ * Return this item rendered to HTML
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+
+ /**
+ * Resolve all macros in the given URL
+ *
+ * @param string $url
+ *
+ * @return string
+ */
+ protected function resolveMacros($url)
+ {
+ if (strpos($url, '$') === false) {
+ return $url;
+ }
+
+ $macros = [];
+ if (Auth::getInstance()->isAuthenticated()) {
+ $macros['$user.local_name$'] = Auth::getInstance()->getUser()->getLocalUsername();
+ }
+ if (! empty($macros)) {
+ $url = str_replace(array_keys($macros), array_values($macros), $url);
+ }
+
+ return $url;
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php
new file mode 100644
index 0000000..8510f70
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php
@@ -0,0 +1,139 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Icinga\Web\Navigation\NavigationItem;
+
+/**
+ * Abstract base class for a NavigationItem with a status badge
+ */
+abstract class BadgeNavigationItemRenderer extends NavigationItemRenderer
+{
+ const STATE_OK = 'ok';
+ const STATE_CRITICAL = 'critical';
+ const STATE_WARNING = 'warning';
+ const STATE_PENDING = 'pending';
+ const STATE_UNKNOWN = 'unknown';
+
+ /**
+ * The tooltip text for the badge
+ *
+ * @var string
+ */
+ protected $title;
+
+ /**
+ * The state identifier being used
+ *
+ * The state identifier defines the background color of the badge.
+ *
+ * @var string
+ */
+ protected $state;
+
+ /**
+ * Set the tooltip text for the badge
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * Return the tooltip text for the badge
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ /**
+ * Set the state identifier to use
+ *
+ * @param string $state
+ *
+ * @return $this
+ */
+ public function setState($state)
+ {
+ $this->state = $state;
+ return $this;
+ }
+
+ /**
+ * Return the state identifier to use
+ *
+ * @return string
+ */
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ /**
+ * Return the amount of items represented by the badge
+ *
+ * @return int
+ */
+ abstract public function getCount();
+
+ /**
+ * Render the given navigation item as HTML anchor with a badge
+ *
+ * @param NavigationItem $item
+ *
+ * @return string
+ */
+ public function render(NavigationItem $item = null)
+ {
+ if ($item === null) {
+ $item = $this->getItem();
+ }
+
+ $cssClass = '';
+ if ($item->getCssClass() !== null) {
+ $cssClass = ' ' . $item->getCssClass();
+ }
+
+ $item->setCssClass('badge-nav-item' . $cssClass);
+ $this->setEscapeLabel(false);
+ $label = $this->view()->escape($item->getLabel());
+ $item->setLabel($this->renderBadge() . $label);
+ $html = parent::render($item);
+ return $html;
+ }
+
+ /**
+ * Render the badge
+ *
+ * @return string
+ */
+ protected function renderBadge()
+ {
+ if ($count = $this->getCount()) {
+ if ($count > 1000000) {
+ $count = round($count, -6) / 1000000 . 'M';
+ } elseif ($count > 1000) {
+ $count = round($count, -3) / 1000 . 'k';
+ }
+
+ $view = $this->view();
+ return sprintf(
+ '<span title="%s" class="badge state-%s">%s</span>',
+ $view->escape($this->getTitle()),
+ $view->escape($this->getState()),
+ $count
+ );
+ }
+
+ return '';
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php
new file mode 100644
index 0000000..577895b
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php
@@ -0,0 +1,44 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Icinga\Application\Hook\HealthHook;
+
+class HealthNavigationRenderer extends BadgeNavigationItemRenderer
+{
+ public function getCount()
+ {
+ $count = 0;
+ $title = null;
+ $worstState = null;
+ foreach (HealthHook::collectHealthData()->select() as $result) {
+ if ($worstState === null || $result->state > $worstState) {
+ $worstState = $result->state;
+ $title = $result->message;
+ $count = 1;
+ } elseif ($worstState === $result->state) {
+ $count++;
+ }
+ }
+
+ switch ($worstState) {
+ case HealthHook::STATE_OK:
+ $count = 0;
+ break;
+ case HealthHook::STATE_WARNING:
+ $this->state = self::STATE_WARNING;
+ break;
+ case HealthHook::STATE_CRITICAL:
+ $this->state = self::STATE_CRITICAL;
+ break;
+ case HealthHook::STATE_UNKNOWN:
+ $this->state = self::STATE_UNKNOWN;
+ break;
+ }
+
+ $this->title = $title;
+
+ return $count;
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php
new file mode 100644
index 0000000..51136ff
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php
@@ -0,0 +1,235 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Util\StringHelper;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Url;
+use Icinga\Web\View;
+
+/**
+ * NavigationItemRenderer
+ */
+class NavigationItemRenderer
+{
+ /**
+ * View
+ *
+ * @var View
+ */
+ protected $view;
+
+ /**
+ * The item being rendered
+ *
+ * @var NavigationItem
+ */
+ protected $item;
+
+ /**
+ * Internal link targets provided by Icinga Web 2
+ *
+ * @var array
+ */
+ protected $internalLinkTargets;
+
+ /**
+ * Whether to escape the label
+ *
+ * @var bool
+ */
+ protected $escapeLabel;
+
+ /**
+ * Create a new NavigationItemRenderer
+ *
+ * @param array $options
+ */
+ public function __construct(array $options = null)
+ {
+ if (! empty($options)) {
+ $this->setOptions($options);
+ }
+
+ $this->internalLinkTargets = array('_main', '_self', '_next');
+ $this->init();
+ }
+
+ /**
+ * Initialize this renderer
+ */
+ public function init()
+ {
+ }
+
+ /**
+ * Set the given options
+ *
+ * @param array $options
+ *
+ * @return $this
+ */
+ public function setOptions(array $options)
+ {
+ foreach ($options as $name => $value) {
+ $setter = 'set' . StringHelper::cname($name);
+ if (method_exists($this, $setter)) {
+ $this->$setter($value);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set the view
+ *
+ * @param View $view
+ *
+ * @return $this
+ */
+ public function setView(View $view)
+ {
+ $this->view = $view;
+ return $this;
+ }
+
+ /**
+ * Return the view
+ *
+ * @return View
+ */
+ public function view()
+ {
+ if ($this->view === null) {
+ $this->setView(Icinga::app()->getViewRenderer()->view);
+ }
+
+ return $this->view;
+ }
+
+ /**
+ * Set the navigation item to render
+ *
+ * @param NavigationItem $item
+ *
+ * @return $this
+ */
+ public function setItem(NavigationItem $item)
+ {
+ $this->item = $item;
+ return $this;
+ }
+
+ /**
+ * Return the navigation item being rendered
+ *
+ * @return NavigationItem
+ */
+ public function getItem()
+ {
+ return $this->item;
+ }
+
+ /**
+ * Set whether to escape the label
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setEscapeLabel($state = true)
+ {
+ $this->escapeLabel = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether to escape the label
+ *
+ * @return bool
+ */
+ public function getEscapeLabel()
+ {
+ return $this->escapeLabel !== null ? $this->escapeLabel : true;
+ }
+
+ /**
+ * Render the given navigation item as HTML anchor
+ *
+ * @param NavigationItem $item
+ *
+ * @return string
+ */
+ public function render(NavigationItem $item = null)
+ {
+ if ($item !== null) {
+ $this->setItem($item);
+ } elseif (($item = $this->getItem()) === null) {
+ throw new ProgrammingError(
+ 'Cannot render nothing. Pass the item to render as part'
+ . ' of the call to render() or set it with setItem()'
+ );
+ }
+
+ $label = $this->getEscapeLabel()
+ ? $this->view()->escape($item->getLabel())
+ : $item->getLabel();
+ if (($icon = $item->getIcon()) !== null) {
+ $label = $this->view()->icon($icon) . $label;
+ } elseif ($item->getName()) {
+ $firstLetter = $item->getName()[0];
+ $label = $this->view()->icon('letter', null, ['data-letter' => strtolower($firstLetter)]) . $label;
+ }
+
+ if (($url = $item->getUrl()) !== null) {
+ $url->overwriteParams($item->getUrlParameters());
+
+ $target = $item->getTarget();
+ if ($url->isExternal() && (!$target || in_array($target, $this->internalLinkTargets, true))) {
+ $url = Url::fromPath('iframe', array('url' => $url));
+ }
+
+ $content = sprintf(
+ '<a%s href="%s"%s>%s</a>',
+ $this->view()->propertiesToString($item->getAttributes()),
+ $this->view()->escape($url->getAbsoluteUrl('&')),
+ $this->renderTargetAttribute(),
+ $label
+ );
+ } elseif ($label) {
+ $content = sprintf(
+ '<%1$s%2$s>%3$s</%1$s>',
+ $item::LINK_ALTERNATIVE,
+ $this->view()->propertiesToString($item->getAttributes()),
+ $label
+ );
+ } else {
+ $content = '';
+ }
+
+ return $content;
+ }
+
+ /**
+ * Render and return the attribute to provide a non-default target for the url
+ *
+ * @return string
+ */
+ protected function renderTargetAttribute()
+ {
+ $target = $this->getItem()->getTarget();
+ if ($target === null || $this->getItem()->getUrl()->getAbsoluteUrl() == '#') {
+ return '';
+ }
+
+ if (! in_array($target, $this->internalLinkTargets, true)) {
+ return ' target="' . $this->view()->escape($target) . '"';
+ }
+
+ return ' data-base-target="' . $target . '"';
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php
new file mode 100644
index 0000000..00c0f9a
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php
@@ -0,0 +1,356 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use ArrayIterator;
+use Exception;
+use RecursiveIterator;
+use Icinga\Application\Icinga;
+use Icinga\Exception\IcingaException;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\View;
+
+/**
+ * Renderer for single level navigation
+ */
+class NavigationRenderer implements RecursiveIterator, NavigationRendererInterface
+{
+ /**
+ * The tag used for the outer element
+ *
+ * @var string
+ */
+ protected $elementTag;
+
+ /**
+ * The CSS class used for the outer element
+ *
+ * @var string
+ */
+ protected $cssClass;
+
+ /**
+ * The navigation's heading text
+ *
+ * @var string
+ */
+ protected $heading;
+
+ /**
+ * The content rendered so far
+ *
+ * @var array
+ */
+ protected $content;
+
+ /**
+ * Whether to skip rendering the outer element
+ *
+ * @var bool
+ */
+ protected $skipOuterElement;
+
+ /**
+ * The navigation's iterator
+ *
+ * @var ArrayIterator
+ */
+ protected $iterator;
+
+ /**
+ * The navigation
+ *
+ * @var Navigation
+ */
+ protected $navigation;
+
+ /**
+ * View
+ *
+ * @var View
+ */
+ protected $view;
+
+ /**
+ * Create a new NavigationRenderer
+ *
+ * @param Navigation $navigation
+ * @param bool $skipOuterElement
+ */
+ public function __construct(Navigation $navigation, $skipOuterElement = false)
+ {
+ $this->skipOuterElement = $skipOuterElement;
+ $this->iterator = $navigation->getIterator();
+ $this->navigation = $navigation;
+ $this->content = array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setElementTag($tag)
+ {
+ $this->elementTag = $tag;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getElementTag()
+ {
+ return $this->elementTag ?: static::OUTER_ELEMENT_TAG;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCssClass($class)
+ {
+ $this->cssClass = $class;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCssClass()
+ {
+ return $this->cssClass;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setHeading($heading)
+ {
+ $this->heading = $heading;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getHeading()
+ {
+ return $this->heading;
+ }
+
+ /**
+ * Return the view
+ *
+ * @return View
+ */
+ public function view()
+ {
+ if ($this->view === null) {
+ $this->setView(Icinga::app()->getViewRenderer()->view);
+ }
+
+ return $this->view;
+ }
+
+ /**
+ * Set the view
+ *
+ * @param View $view
+ *
+ * @return $this
+ */
+ public function setView(View $view)
+ {
+ $this->view = $view;
+ return $this;
+ }
+
+ public function getChildren(): NavigationRenderer
+ {
+ return new static($this->current()->getChildren(), $this->skipOuterElement);
+ }
+
+ public function hasChildren(): bool
+ {
+ return $this->current()->hasChildren();
+ }
+
+ public function current(): NavigationItem
+ {
+ return $this->iterator->current();
+ }
+
+ public function key(): int
+ {
+ return $this->iterator->key();
+ }
+
+ public function next(): void
+ {
+ $this->iterator->next();
+ }
+
+ public function rewind(): void
+ {
+ $this->iterator->rewind();
+ if (! $this->skipOuterElement) {
+ $this->content[] = $this->beginMarkup();
+ }
+ }
+
+ public function valid(): bool
+ {
+ $valid = $this->iterator->valid();
+ if (! $this->skipOuterElement && !$valid) {
+ $this->content[] = $this->endMarkup();
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Return the opening markup for the navigation
+ *
+ * @return string
+ */
+ public function beginMarkup()
+ {
+ $content = array();
+ $content[] = sprintf(
+ '<%s%s role="navigation">',
+ $this->getElementTag(),
+ $this->getCssClass() !== null ? ' class="' . $this->getCssClass() . '"' : ''
+ );
+ if (($heading = $this->getHeading()) !== null) {
+ $content[] = sprintf(
+ '<h%1$d id="navigation" class="sr-only" tabindex="-1">%2$s</h%1$d>',
+ static::HEADING_RANK,
+ $this->view()->escape($heading)
+ );
+ }
+ $content[] = $this->beginChildrenMarkup();
+ return join("\n", $content);
+ }
+
+ /**
+ * Return the closing markup for the navigation
+ *
+ * @return string
+ */
+ public function endMarkup()
+ {
+ $content = array();
+ $content[] = $this->endChildrenMarkup();
+ $content[] = '</' . $this->getElementTag() . '>';
+ return join("\n", $content);
+ }
+
+ /**
+ * Return the opening markup for multiple navigation items
+ *
+ * @param int $level
+ *
+ * @return string
+ */
+ public function beginChildrenMarkup($level = 1)
+ {
+ $cssClass = array(static::CSS_CLASS_NAV);
+ if ($this->navigation->getLayout() === Navigation::LAYOUT_TABS) {
+ $cssClass[] = static::CSS_CLASS_NAV_TABS;
+ } elseif ($this->navigation->getLayout() === Navigation::LAYOUT_DROPDOWN) {
+ $cssClass[] = static::CSS_CLASS_NAV_DROPDOWN;
+ }
+
+ $cssClass[] = 'nav-level-' . $level;
+
+ return '<ul class="' . join(' ', $cssClass) . '">';
+ }
+
+ /**
+ * Return the closing markup for multiple navigation items
+ *
+ * @return string
+ */
+ public function endChildrenMarkup()
+ {
+ return '</ul>';
+ }
+
+ /**
+ * Return the opening markup for the given navigation item
+ *
+ * @param NavigationItem $item
+ *
+ * @return string
+ */
+ public function beginItemMarkup(NavigationItem $item)
+ {
+ $cssClasses = array(static::CSS_CLASS_ITEM);
+
+ if ($item->hasChildren() && $item->getChildren()->getLayout() === Navigation::LAYOUT_DROPDOWN) {
+ $cssClasses[] = static::CSS_CLASS_DROPDOWN;
+ $item
+ ->setAttribute('class', static::CSS_CLASS_DROPDOWN_TOGGLE)
+ ->setIcon(static::DROPDOWN_TOGGLE_ICON)
+ ->setUrl('#');
+ }
+
+ if ($item->getActive()) {
+ $cssClasses[] = static::CSS_CLASS_ACTIVE;
+ }
+
+ if ($item->getSelected()) {
+ $cssClasses[] = static::CSS_CLASS_SELECTED;
+ }
+
+ if ($cssClass = $item->getCssClass()) {
+ $cssClasses[] = $cssClass;
+ }
+
+ $content = sprintf(
+ '<li class="%s">',
+ join(' ', $cssClasses)
+ );
+ return $content;
+ }
+
+ /**
+ * Return the closing markup for a navigation item
+ *
+ * @return string
+ */
+ public function endItemMarkup()
+ {
+ return '</li>';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ foreach ($this as $item) {
+ /** @var NavigationItem $item */
+ if ($item->shouldRender()) {
+ $content = $item->render();
+ $this->content[] = $this->beginItemMarkup($item);
+ $this->content[] = $content;
+ $this->content[] = $this->endItemMarkup();
+ }
+ }
+
+ return join("\n", $this->content);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php
new file mode 100644
index 0000000..4495b73
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php
@@ -0,0 +1,142 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+/**
+ * Interface for navigation renderers
+ */
+interface NavigationRendererInterface
+{
+ /**
+ * CSS class for items
+ *
+ * @var string
+ */
+ const CSS_CLASS_ITEM = 'nav-item';
+
+ /**
+ * CSS class for active items
+ *
+ * @var string
+ */
+ const CSS_CLASS_ACTIVE = 'active';
+
+ /**
+ * CSS class for selected items
+ *
+ * @var string
+ */
+ const CSS_CLASS_SELECTED = 'selected';
+
+ /**
+ * CSS class for dropdown items
+ *
+ * @var string
+ */
+ const CSS_CLASS_DROPDOWN = 'dropdown-nav-item';
+
+ /**
+ * CSS class for a dropdown item's trigger
+ *
+ * @var string
+ */
+ const CSS_CLASS_DROPDOWN_TOGGLE = 'dropdown-toggle';
+
+ /**
+ * CSS class for the ul element
+ *
+ * @var string
+ */
+ const CSS_CLASS_NAV = 'nav';
+
+ /**
+ * CSS class for the ul element with dropdown layout
+ *
+ * @var string
+ */
+ const CSS_CLASS_NAV_DROPDOWN = 'dropdown-nav';
+
+ /**
+ * CSS class for the ul element with tabs layout
+ *
+ * @var string
+ */
+ const CSS_CLASS_NAV_TABS = 'tab-nav';
+
+ /**
+ * Icon for a dropdown item's trigger
+ *
+ * @var string
+ */
+ const DROPDOWN_TOGGLE_ICON = 'menu';
+
+ /**
+ * Default tag for the outer element the navigation will be wrapped with
+ *
+ * @var string
+ */
+ const OUTER_ELEMENT_TAG = 'div';
+
+ /**
+ * The heading's rank
+ *
+ * @var int
+ */
+ const HEADING_RANK = 1;
+
+ /**
+ * Set the tag for the outer element the navigation is wrapped with
+ *
+ * @param string $tag
+ *
+ * @return $this
+ */
+ public function setElementTag($tag);
+
+ /**
+ * Return the tag for the outer element the navigation is wrapped with
+ *
+ * @return string
+ */
+ public function getElementTag();
+
+ /**
+ * Set the CSS class to use for the outer element
+ *
+ * @param string $class
+ *
+ * @return $this
+ */
+ public function setCssClass($class);
+
+ /**
+ * Get the CSS class used for the outer element
+ *
+ * @return string
+ */
+ public function getCssClass();
+
+ /**
+ * Set the navigation's heading text
+ *
+ * @param string $heading
+ *
+ * @return $this
+ */
+ public function setHeading($heading);
+
+ /**
+ * Return the navigation's heading text
+ *
+ * @return string
+ */
+ public function getHeading();
+
+ /**
+ * Return the navigation rendered to HTML
+ *
+ * @return string
+ */
+ public function render();
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php
new file mode 100644
index 0000000..315c2aa
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php
@@ -0,0 +1,186 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+use Exception;
+use RecursiveIteratorIterator;
+use Icinga\Exception\IcingaException;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+
+/**
+ * Renderer for multi level navigation
+ *
+ * @method NavigationRenderer getInnerIterator() {
+ * {@inheritdoc}
+ * }
+ */
+class RecursiveNavigationRenderer extends RecursiveIteratorIterator implements NavigationRendererInterface
+{
+ /**
+ * The content rendered so far
+ *
+ * @var array
+ */
+ protected $content;
+
+ /**
+ * Whether to use the standard item renderer
+ *
+ * @var bool
+ */
+ protected $useStandardRenderer;
+
+ /**
+ * Create a new RecursiveNavigationRenderer
+ *
+ * @param Navigation $navigation
+ */
+ public function __construct(Navigation $navigation)
+ {
+ $this->content = array();
+ parent::__construct(
+ new NavigationRenderer($navigation, true),
+ RecursiveIteratorIterator::SELF_FIRST
+ );
+ }
+
+ /**
+ * Set whether to use the standard navigation item renderer
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setUseStandardItemRenderer($state = true)
+ {
+ $this->useStandardRenderer = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether to use the standard navigation item renderer
+ *
+ * @return bool
+ */
+ public function getUseStandardItemRenderer()
+ {
+ return $this->useStandardRenderer;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setElementTag($tag)
+ {
+ $this->getInnerIterator()->setElementTag($tag);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getElementTag()
+ {
+ return $this->getInnerIterator()->getElementTag();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCssClass($class)
+ {
+ $this->getInnerIterator()->setCssClass($class);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCssClass()
+ {
+ return $this->getInnerIterator()->getCssClass();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setHeading($heading)
+ {
+ $this->getInnerIterator()->setHeading($heading);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getHeading()
+ {
+ return $this->getInnerIterator()->getHeading();
+ }
+
+ public function beginIteration(): void
+ {
+ $this->content[] = $this->getInnerIterator()->beginMarkup();
+ }
+
+ public function endIteration(): void
+ {
+ $this->content[] = $this->getInnerIterator()->endMarkup();
+ }
+
+ public function beginChildren(): void
+ {
+ $this->content[] = $this->getInnerIterator()->beginChildrenMarkup($this->getDepth() + 1);
+ }
+
+ public function endChildren(): void
+ {
+ $this->content[] = $this->getInnerIterator()->endChildrenMarkup();
+ $this->content[] = $this->getInnerIterator()->endItemMarkup();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ foreach ($this as $item) {
+ /** @var NavigationItem $item */
+ if ($item->shouldRender()) {
+ if ($this->getDepth() > 0) {
+ $item->setIcon(null);
+ }
+ if ($this->getUseStandardItemRenderer()) {
+ $renderer = new NavigationItemRenderer();
+ $content = $renderer->render($item);
+ } else {
+ $content = $item->render();
+ }
+ $this->content[] = $this->getInnerIterator()->beginItemMarkup($item);
+
+ $this->content[] = $content;
+
+ if (! $item->hasChildren()) {
+ $this->content[] = $this->getInnerIterator()->endItemMarkup();
+ }
+ }
+ }
+
+ return join("\n", $this->content);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return IcingaException::describe($e);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php
new file mode 100644
index 0000000..2916f4e
--- /dev/null
+++ b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php
@@ -0,0 +1,72 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Navigation\Renderer;
+
+/**
+ * Badge renderer summing up the worst state of its children
+ */
+class SummaryNavigationItemRenderer extends BadgeNavigationItemRenderer
+{
+ /**
+ * Cached count
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * State to severity map
+ *
+ * @var array
+ */
+ protected static $stateSeverityMap = array(
+ self::STATE_OK => 0,
+ self::STATE_PENDING => 1,
+ self::STATE_UNKNOWN => 2,
+ self::STATE_WARNING => 3,
+ self::STATE_CRITICAL => 4,
+ );
+
+ /**
+ * Severity to state map
+ *
+ * @var array
+ */
+ protected static $severityStateMap = array(
+ self::STATE_OK,
+ self::STATE_PENDING,
+ self::STATE_UNKNOWN,
+ self::STATE_WARNING,
+ self::STATE_CRITICAL
+ );
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCount()
+ {
+ if ($this->count === null) {
+ $countMap = array_fill(0, 5, 0);
+ $maxSeverity = 0;
+ $titles = array();
+ foreach ($this->getItem()->getChildren() as $child) {
+ $renderer = $child->getRenderer();
+ if ($renderer instanceof BadgeNavigationItemRenderer) {
+ $count = $renderer->getCount();
+ if ($count) {
+ $severity = static::$stateSeverityMap[$renderer->getState()];
+ $countMap[$severity] += $count;
+ $titles[] = $renderer->getTitle();
+ $maxSeverity = max($maxSeverity, $severity);
+ }
+ }
+ }
+ $this->count = $countMap[$maxSeverity];
+ $this->state = static::$severityStateMap[$maxSeverity];
+ $this->title = implode('. ', $titles);
+ }
+
+ return $this->count;
+ }
+}