diff options
Diffstat (limited to '')
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; + } +} |