diff --git a/application/views/scripts/layout/menu.phtml b/application/views/scripts/layout/menu.phtml index c82d5cc84..dfb544d63 100644 --- a/application/views/scripts/layout/menu.phtml +++ b/application/views/scripts/layout/menu.phtml @@ -1,5 +1,6 @@ search('dummy')->getPane('search')->hasDashlets()): ?> setCssClass('primary-nav')->setElementTag('nav')->setHeading(t('Navigation')); ?> + diff --git a/library/Icinga/Web/Menu.php b/library/Icinga/Web/Menu.php index 3289b56d9..dc1cdc82a 100644 --- a/library/Icinga/Web/Menu.php +++ b/library/Icinga/Web/Menu.php @@ -33,6 +33,7 @@ class Menu extends Navigation 'priority' => 10 ]); $this->addItem('system', [ + 'cssClass' => 'system-nav-item', 'label' => t('System'), 'icon' => 'services', 'priority' => 700, @@ -74,6 +75,7 @@ class Menu extends Navigation ] ]); $this->addItem('configuration', [ + 'cssClass' => 'configuration-nav-item', 'label' => t('Configuration'), 'icon' => 'wrench', 'permission' => 'config/*', diff --git a/library/Icinga/Web/Navigation/ConfigMenu.php b/library/Icinga/Web/Navigation/ConfigMenu.php new file mode 100644 index 000000000..ca4540bcf --- /dev/null +++ b/library/Icinga/Web/Navigation/ConfigMenu.php @@ -0,0 +1,300 @@ + 'nav']; + + protected $children; + + protected $selected; + + protected $cogItemActive = false; + + protected $state; + + public function __construct() + { + $this->children = [ + 'system' => [ + 'title' => t('System'), + 'items' => [ + 'about' => [ + 'label' => t('About'), + 'url' => 'about' + ], + 'health' => [ + 'label' => t('Health'), + 'url' => 'health', + ], + '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' + ], + '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' + ] + ] + ] + ]; + } + + 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->createLevel2Menu() + ]); + + if ($this->cogItemActive) { + $cogMenuItem->addAttributes([ 'class' => 'active' ]); + } + } + + 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; + $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; + } + + 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 = null; + if ($this->getHealthCount() > 0) { + $stateBadge = new StateBadge($this->getHealthCount(), $this->state); + $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; + } + + $healthBadge = null; + $class = null; + if ($key === 'health') { + $class = 'badge-nav-item'; + $healthBadge = $this->createHealthBadge(); + } + + $li = HtmlElement::create( + 'li', + isset($item['atts']) ? $item['atts'] : [], + [ + HtmlElement::create( + 'a', + Attributes::create(['href' => $item['url']]), + [ + $item['label'], + isset($healthBadge) ? $healthBadge : '' + ] + ), + ] + ); + $li->addAttributes(['class' => $class]); + + if ($this->isSelectedItem($item)) { + $li->addAttributes(['class' => 'selected']); + $this->cogItemActive = true; + } + + 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/Renderer/BadgeNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php index cabac9ae9..8510f7070 100644 --- a/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php +++ b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php @@ -97,7 +97,13 @@ abstract class BadgeNavigationItemRenderer extends NavigationItemRenderer if ($item === null) { $item = $this->getItem(); } - $item->setCssClass('badge-nav-item'); + + $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); diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index 6da3fc9b0..02087302d 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -56,6 +56,7 @@ class StyleSheet 'css/vendor/normalize.css', 'css/icinga/base.less', 'css/icinga/badges.less', + 'css/icinga/configmenu.less', 'css/icinga/mixins.less', 'css/icinga/grid.less', 'css/icinga/nav.less', diff --git a/modules/monitoring/configuration.php b/modules/monitoring/configuration.php index 8415f889c..663db93f5 100644 --- a/modules/monitoring/configuration.php +++ b/modules/monitoring/configuration.php @@ -289,18 +289,6 @@ $section = $this->menuSection(N_('Reporting'), array( 'priority' => 100 )); -/* - * System Section - */ -$section = $this->menuSection(N_('System')); -$section->add(N_('Monitoring Health'), array( - 'icon' => 'check', - 'description' => $this->translate('Open monitoring health'), - 'url' => 'monitoring/health/info', - 'priority' => 720, - 'renderer' => 'BackendAvailabilityNavigationItemRenderer' -)); - /* * Current Incidents */ diff --git a/public/css/icinga/configmenu.less b/public/css/icinga/configmenu.less new file mode 100644 index 000000000..05e50e864 --- /dev/null +++ b/public/css/icinga/configmenu.less @@ -0,0 +1,303 @@ +#menu { + margin-bottom: 3em; +} + +.sidebar-collapsed #menu { + margin-bottom: 8em; +} + +#menu .config-menu { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background-color: @menu-bg-color; + margin-top: auto; + + > ul { + display: flex; + flex-wrap: nowrap; + padding: 0; + + > li { + > a { + padding: 0.5em 0.5em 0.5em 0.75em; + line-height: 2.167em; + white-space: nowrap; + text-decoration: none; + + } + + &:hover .nav-level-1 { + display: block; + } + } + + li.active a:after { + display: none; + } + + .user-nav-item { + width: 100%; + overflow: hidden; // necessary for .text-ellipsis of + + > a { + overflow: hidden; + text-overflow: ellipsis; + } + + &:not(.active):hover a, + &:not(.active) a:focus { + background: @menu-hover-bg-color; + } + } + + .config-nav-item { + line-height: 2; + display: flex; + align-items: center; + position: relative; + + > button { + background: none; + border: none; + display: block; + .rounded-corners(); + + > .state-badge { + position: absolute; + pointer-events: none; + } + + .icon { + opacity: .8; + font-size: 1.25em; + + &:before { + margin-right: 0; + } + } + } + + &:hover > button { + background: fade(@menu-hover-bg-color, 25); + + > .state-badge { + display: none; + } + } + + button:focus { + background: fade(@menu-hover-bg-color, 25); + } + + &.active > button { + color: @text-color-inverted; + background: @icinga-blue; + } + } + + .state-badge { + line-height: 1.2; + padding: .25em; + font-family: @font-family-wide; + } + } + + .nav-level-1 li { + &.badge-nav-item > a { + display: flex; + align-items: baseline; + width: 100%; + + .state-badge { + margin-left: auto; + } + } + } + + .nav-item-logout { + color: @color-critical; + border-top: 1px solid @gray-lighter; + } + + .user-ball { + .ball(); + .ball-size-l(); + .ball-solid(@icinga-blue); + + // icingadb-web/public/css/common.less: .user-ball + font-weight: bold; + text-transform: uppercase; + + // compensate border vertically and add space to the right; + margin: -1px .2em -2px 0; + border: 1px solid @text-color-inverted; + font-style: normal; + line-height: 1.2; + } +} + +#layout:not(.sidebar-collapsed) #menu .config-menu { + .user-nav-item { + > a { + padding-right: 4.75em; + } + + &.active.selected + .config-nav-item { + > button { + color: @text-color-inverted; + } + } + } + + .config-nav-item { + position: absolute; + right: 2.5em; + bottom: 0; + top: 0; + + .state-badge { + left: -1em; + top: 0; + } + } + + .flyout { + bottom: 100%; + right: -2em; + width: 15em; + } +} + +.sidebar-collapsed #menu .config-menu { + ul { + flex-direction: column; + + .user-ball { + margin-left: .25em * 1.5/2; + margin-right: .5em + .25em * 1.5/2; + width: 2em * 1.5/2 ; + height: 2em * 1.5/2; + font-size: 2/1.5em; + line-height: 1; + } + + .config-nav-item { + padding-right: 0; + margin-bottom: 3em; + + .icon { + font-size: 1.5em; + } + + button { + position: relative; + width: 3em; + margin: .125em .5em; + padding: .5em .75em; + + .state-badge { + right: -.25em; + bottom: -.25em; + font-size: .75em; + + overflow: hidden; + text-overflow: ellipsis; + max-width: 4em; + } + } + } + } + + .flyout { + bottom: 0; + left: 100%; + width: 14em; + + &:before { + left: -.6em; + bottom: 1em; + transform: rotate(135deg); + } + } +} + +.flyout { + display: none; + position: absolute; + border: 1px solid @gray-lighter; + background: @body-bg-color; + box-shadow: 0 0 1em 0 rgba(0,0,0,.25); + z-index: 1; + .rounded-corners(); + + a { + font-size: 11/12em; + padding: 0.364em 0.545em 0.364em 2em; + line-height: 2; + + &:hover { + text-decoration: none; + background: @menu-2ndlvl-highlight-bg-color; + } + } + + h3 { + font-size: 10/12em; + color: @text-color-light; + letter-spacing: .1px; + padding: 0.364em 0.545em 0.364em 0.545em; + margin: 0; + } + + .flyout-content { + overflow: auto; + // Partially escape to have ems calculated + max-height: calc(~"100vh - " 50/12em); + padding: .5em 0; + position: relative; + } + + // Caret + &:before { + content: ""; + display: block; + position: absolute; + transform: rotate(45deg); + background: @body-bg-color; + border-bottom: 1px solid @gray-lighter; + border-right: 1px solid @gray-lighter; + height: 1.1em; + width: 1.1em; + bottom: -.6em; + right: 2.5em; + } +} + +// Prevent flyout to vanish on autorefresh +#layout.config-flyout-open .config-nav-item { + .flyout { + display: block; + } + + > button > .state-badge { + display: none; + } +} + +#layout.minimal-layout .config-menu { + display: none; +} + +#layout.minimal-layout #menu { + margin-bottom: 0; +} + +#layout:not(.minimal-layout) #menu .primary-nav { + .user-nav-item, + .configuration-nav-item, + .system-nav-item { + display: none; + } +} diff --git a/public/css/icinga/layout.less b/public/css/icinga/layout.less index f2a97e100..c37da7920 100644 --- a/public/css/icinga/layout.less +++ b/public/css/icinga/layout.less @@ -2,12 +2,20 @@ #footer { bottom: 0; - left: 0; right: 0; + left: 12em; position: fixed; z-index: 999; } +#layout.minimal-layout #footer { + left: 0; +} + +.sidebar-collapsed #footer { + left: 3em; +} + #guest-error { background-color: @icinga-blue; height: 100%; @@ -208,10 +216,6 @@ padding: 0; } -#layout:not(.minimal-layout) #notifications { - padding-left: 12em; -} - #notifications > li { color: @text-color; display: block; diff --git a/public/css/icinga/menu.less b/public/css/icinga/menu.less index 6101c3806..7933b6358 100644 --- a/public/css/icinga/menu.less +++ b/public/css/icinga/menu.less @@ -17,15 +17,6 @@ overflow-x: hidden; } -#layout:not(.minimal-layout) #menu { - // Space for the #toggle-sidebar button - &:after { - content: ""; - display: block; - padding-bottom: 2.25em; - } -} - #menu .nav-item { vertical-align: middle; @@ -480,9 +471,10 @@ input[type=text].search-input { padding: 0; color: @text-color-light; position: absolute; - bottom: 0.2em; + bottom: 0; right: 0; z-index: 3; + line-height: 2; i { background-color: @body-bg-color; diff --git a/public/css/icinga/widgets.less b/public/css/icinga/widgets.less index 8015537eb..949a6bc03 100644 --- a/public/css/icinga/widgets.less +++ b/public/css/icinga/widgets.less @@ -68,7 +68,6 @@ color: @text-color-on-icinga-blue; line-height: 1.5em; padding: 0.5em 1em 0.5em 3em; - margin-left: 12em; position: relative; diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js index 7186d9040..a8c507b33 100644 --- a/public/js/icinga/behavior/navigation.js +++ b/public/js/icinga/behavior/navigation.js @@ -11,10 +11,16 @@ this.on('click', '#menu a', this.linkClicked, this); this.on('click', '#menu tr[href]', this.linkClicked, this); this.on('rendered', '#menu', this.onRendered, this); - this.on('mouseenter', '#menu .nav-level-1 > .nav-item', this.showFlyoutMenu, this); - this.on('mouseleave', '#menu', this.hideFlyoutMenu, this); + this.on('mouseenter', '#menu .primary-nav .nav-level-1 > .nav-item', this.showFlyoutMenu, this); + this.on('mouseleave', '#menu .primary-nav', this.hideFlyoutMenu, this); this.on('click', '#toggle-sidebar', this.toggleSidebar, this); + this.on('click', '#menu .config-nav-item button', this.toggleConfigFlyout, this); + this.on('mouseenter', '#menu .config-menu .config-nav-item', this.showConfigFlyout, this); + this.on('mouseleave', '#menu .config-menu .config-nav-item', this.hideConfigFlyout, this); + + this.on('keydown', '#menu .config-menu .config-nav-item', this.onKeyDown, this); + /** * The DOM-Path of the active item * @@ -121,6 +127,7 @@ } else { _this.setActiveAndSelected($(event.target)); } + // update target url of the menu container to the clicked link var $menu = $('#menu'); var menuDataUrl = icinga.utils.parseUrl($menu.data('icinga-url')); @@ -322,7 +329,8 @@ */ Navigation.prototype.hideFlyoutMenu = function(e) { var $layout = $('#layout'); - var $hovered = $('#menu').find('.nav-level-1 > .nav-item.hover'); + var $nav = $(e.currentTarget); + var $hovered = $nav.find('.nav-level-1 > .nav-item.hover'); if (! $hovered.length) { $layout.removeClass('menu-hovered'); @@ -332,7 +340,7 @@ setTimeout(function() { try { - if ($hovered.is(':hover') || $('#menu').is(':hover')) { + if ($hovered.is(':hover') || $nav.is(':hover')) { return; } } catch(e) { /* Bypass because if IE8 */ }; @@ -354,6 +362,55 @@ $(window).trigger('resize'); }; + /** + * Toggle config flyout visibility + * + * @param {Object} e Event + */ + Navigation.prototype.toggleConfigFlyout = function(e) { + var _this = e.data.self; + if ($('#layout').is('.config-flyout-open')) { + _this.hideConfigFlyout(e); + } else { + _this.showConfigFlyout(e); + } + } + + /** + * Hide config flyout + * + * @param {Object} e Event + */ + Navigation.prototype.hideConfigFlyout = function(e) { + $('#layout').removeClass('config-flyout-open'); + if (e.target) { + delete $(e.target).closest('.container')[0].dataset.suspendAutorefresh; + } + } + + /** + * Show config flyout + * + * @param {Object} e Event + */ + Navigation.prototype.showConfigFlyout = function(e) { + $('#layout').addClass('config-flyout-open'); + $(e.target).closest('.container')[0].dataset.suspendAutorefresh = ''; + } + + /** + * Hide, config flyout when "Enter" key is pressed to follow `.flyout` nav item link + * + * @param {Object} e Event + */ + Navigation.prototype.onKeyDown = function(e) { + var _this = e.data.self; + + if (e.key == 'Enter' && $(document.activeElement).is('.flyout a')) { + _this.hideConfigFlyout(e); + } + } + /** * Called when the history changes *