diff --git a/application/controllers/LayoutController.php b/application/controllers/LayoutController.php index 335bee8e0..c7e814119 100644 --- a/application/controllers/LayoutController.php +++ b/application/controllers/LayoutController.php @@ -3,11 +3,8 @@ namespace Icinga\Controllers; +use Icinga\Application\Icinga; use Icinga\Web\Controller\ActionController; -use Icinga\Web\Hook; -use Icinga\Web\Menu; -use Icinga\Web\MenuRenderer; -use Icinga\Web\Url; /** * Create complex layout parts @@ -21,9 +18,6 @@ class LayoutController extends ActionController { $this->setAutorefreshInterval(15); $this->_helper->layout()->disableLayout(); - - $url = Url::fromRequest(); - $menu = new MenuRenderer(Menu::load(), $url->getRelativeUrl()); - $this->view->menuRenderer = $menu->useCustomRenderer(); + $this->view->menuRenderer = Icinga::app()->getMenu()->getRenderer(); } } diff --git a/application/controllers/NavigationController.php b/application/controllers/NavigationController.php new file mode 100644 index 000000000..f5c1d785a --- /dev/null +++ b/application/controllers/NavigationController.php @@ -0,0 +1,346 @@ +defaultItemTypes = array( + 'menu-item' => $this->translate('Menu Entry'), + 'dashlet' => 'Dashlet' + ); + } + + /** + * Return a list of available navigation item types + * + * @return array + */ + protected function listItemTypes() + { + $moduleManager = Icinga::app()->getModuleManager(); + + $types = $this->defaultItemTypes; + foreach ($moduleManager->getLoadedModules() as $module) { + if ($this->hasPermission($moduleManager::MODULE_PERMISSION_NS . $module->getName())) { + $moduleTypes = $module->getNavigationItems(); + if (! empty($moduleTypes)) { + $types = array_merge($types, $moduleTypes); + } + } + } + + return $types; + } + + /** + * Show the current user a list of his/her navigation items + */ + public function indexAction() + { + $user = $this->Auth()->getUser(); + + $ds = new ArrayDatasource(array_merge( + Config::app('navigation')->select()->where('owner', $user->getUsername())->fetchAll(), + iterator_to_array($user->loadNavigationConfig()) + )); + $ds->setKeyColumn('name'); + $query = $ds->select(); + + $this->view->types = $this->listItemTypes(); + $this->view->items = $query; + + $this->getTabs()->add( + 'navigation', + array( + 'title' => $this->translate('List and configure your own navigation items'), + 'label' => $this->translate('Navigation'), + 'url' => 'navigation' + ) + )->activate('navigation'); + $this->setupSortControl( + array( + 'type' => $this->translate('Type'), + 'owner' => $this->translate('Shared'), + 'name' => $this->translate('Shared Navigation') + ), + $query + ); + } + + /** + * List all shared navigation items + */ + public function sharedAction() + { + $this->assertPermission('config/application/navigation'); + $config = Config::app('navigation'); + $config->getConfigObject()->setKeyColumn('name'); + $query = $config->select(); + + $removeForm = new Form(); + $removeForm->setUidDisabled(); + $removeForm->setAction(Url::fromPath('navigation/unshare')); + $removeForm->addElement('hidden', 'name', array( + 'decorators' => array('ViewHelper') + )); + $removeForm->addElement('hidden', 'redirect', array( + 'value' => Url::fromPath('navigation/shared'), + 'decorators' => array('ViewHelper') + )); + $removeForm->addElement('button', 'btn_submit', array( + 'escape' => false, + 'type' => 'submit', + 'class' => 'link-like spinner', + 'value' => 'btn_submit', + 'decorators' => array('ViewHelper'), + 'label' => $this->view->icon('trash'), + 'title' => $this->translate('Unshare this navigation item') + )); + + $this->view->removeForm = $removeForm; + $this->view->types = $this->listItemTypes(); + $this->view->items = $query; + + $this->getTabs()->add( + 'navigation/shared', + array( + 'title' => $this->translate('List and configure shared navigation items'), + 'label' => $this->translate('Shared Navigation'), + 'url' => 'navigation/shared' + ) + )->activate('navigation/shared'); + $this->setupSortControl( + array( + 'type' => $this->translate('Type'), + 'owner' => $this->translate('Owner'), + 'name' => $this->translate('Shared Navigation') + ), + $query + ); + } + + /** + * Add a navigation item + */ + public function addAction() + { + $form = new NavigationConfigForm(); + $form->setRedirectUrl('navigation'); + $form->setItemTypes($this->listItemTypes()); + $form->setTitle($this->translate('Create New Navigation Item')); + $form->addDescription($this->translate('Create a new navigation item, such as a menu entry or dashlet.')); + $form->setUser($this->Auth()->getUser()); + $form->setShareConfig(Config::app('navigation')); + $form->setOnSuccess(function (NavigationConfigForm $form) { + $data = array_filter($form->getValues()); + + try { + $form->add($data); + } catch (Exception $e) { + $form->error($e->getMessage()); + return false; + } + + if ($form->save()) { + if (isset($data['type']) && $data['type'] === 'menu-item') { + $form->getResponse()->setRerenderLayout(); + } + + Notification::success(t('Navigation item successfully created')); + return true; + } + + return false; + }); + $form->handleRequest(); + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Edit a navigation item + */ + public function editAction() + { + $itemName = $this->params->getRequired('name'); + $referrer = $this->params->get('referrer', 'index'); + + $form = new NavigationConfigForm(); + $form->setRedirectUrl($referrer === 'shared' ? 'navigation/shared' : 'navigation'); + $form->setItemTypes($this->listItemTypes()); + $form->setTitle(sprintf($this->translate('Edit Navigation Item %s'), $itemName)); + $form->setUser($this->Auth()->getUser()); + $form->setShareConfig(Config::app('navigation')); + $form->setOnSuccess(function (NavigationConfigForm $form) use ($itemName) { + $data = array_map( + function ($v) { + return $v !== '' ? $v : null; + }, + $form->getValues() + ); + + try { + $form->edit($itemName, $data); + } catch (NotFoundError $e) { + throw $e; + } catch (Exception $e) { + $form->error($e->getMessage()); + return false; + } + + if ($form->save()) { + if (isset($data['type']) && $data['type'] === 'menu-item') { + $form->getResponse()->setRerenderLayout(); + } + + Notification::success(sprintf(t('Navigation item "%s" successfully updated'), $itemName)); + return true; + } + + return false; + }); + + try { + $form->load($itemName); + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('Navigation item "%s" not found'), $itemName)); + } + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Remove a navigation item + */ + public function removeAction() + { + $itemName = $this->params->getRequired('name'); + + $navigationConfigForm = new NavigationConfigForm(); + $navigationConfigForm->setUser($this->Auth()->getUser()); + $navigationConfigForm->setShareConfig(Config::app('navigation')); + $form = new ConfirmRemovalForm(); + $form->setRedirectUrl('navigation'); + $form->setTitle(sprintf($this->translate('Remove Navigation Item %s'), $itemName)); + $form->setOnSuccess(function (ConfirmRemovalForm $form) use ($itemName, $navigationConfigForm) { + try { + $itemConfig = $navigationConfigForm->delete($itemName); + } catch (NotFoundError $e) { + Notification::success(sprintf(t('Navigation Item "%s" not found. No action required'), $itemName)); + return true; + } catch (Exception $e) { + $form->error($e->getMessage()); + return false; + } + + if ($navigationConfigForm->save()) { + if ($itemConfig->type === 'menu-item') { + $form->getResponse()->setRerenderLayout(); + } + + Notification::success(sprintf(t('Navigation Item "%s" successfully removed'), $itemName)); + return true; + } + + return false; + }); + $form->handleRequest(); + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Unshare a navigation item + */ + public function unshareAction() + { + $this->assertPermission('config/application/navigation'); + $this->assertHttpMethod('POST'); + + $navigationConfigForm = new NavigationConfigForm(); + $navigationConfigForm->setUser($this->Auth()->getUser()); + $navigationConfigForm->setShareConfig(Config::app('navigation')); + + $form = new Form(array( + 'onSuccess' => function ($form) use ($navigationConfigForm) { + $itemName = $form->getValue('name'); + + try { + $newConfig = $navigationConfigForm->unshare($itemName); + if ($navigationConfigForm->save()) { + if ($newConfig->getSection($itemName)->type === 'menu-item') { + $form->getResponse()->setRerenderLayout(); + } + + Notification::success(sprintf( + t('Navigation item "%s" has been unshared'), + $form->getValue('name') + )); + } else { + // TODO: It failed obviously to write one of the configs, so we're leaving the user in + // a inconsistent state. Luckily, it's nothing lost but possibly duplicated... + Notification::error(sprintf( + t('Failed to unshare navigation item "%s"'), + $form->getValue('name') + )); + } + } catch (NotFoundError $e) { + throw $e; + } catch (Exception $e) { + Notification::error($e->getMessage()); + } + + $redirect = $form->getValue('redirect'); + if (! empty($redirect)) { + $form->setRedirectUrl(htmlspecialchars_decode($redirect)); + } + + return true; + } + )); + $form->setUidDisabled(); + $form->setSubmitLabel('btn_submit'); // Required to ensure that isSubmitted() is called + $form->addElement('hidden', 'name', array('required' => true)); + $form->addElement('hidden', 'redirect'); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('Navigation item "%s" not found'), $form->getValue('name'))); + } + } +} diff --git a/application/controllers/PreferenceController.php b/application/controllers/PreferenceController.php index 6437a7c97..6bf407843 100644 --- a/application/controllers/PreferenceController.php +++ b/application/controllers/PreferenceController.php @@ -32,7 +32,7 @@ class PreferenceController extends BasePreferenceController array( 'title' => t('Adjust the preferences of Icinga Web 2 according to your needs'), 'label' => t('Preferences'), - 'url' => Url::fromPath('/preference') + 'url' => Url::fromPath('preference') ) ) ); diff --git a/application/forms/ConfigForm.php b/application/forms/ConfigForm.php index 58d550793..3f3ba227f 100644 --- a/application/forms/ConfigForm.php +++ b/application/forms/ConfigForm.php @@ -43,7 +43,7 @@ class ConfigForm extends Form public function save() { try { - $this->config->saveIni(); + $this->writeConfig($this->config); } catch (Exception $e) { $this->addDecorator('ViewScript', array( 'viewModule' => 'default', @@ -58,4 +58,14 @@ class ConfigForm extends Form return true; } + + /** + * Write the configuration to disk + * + * @param Config $config + */ + protected function writeConfig(Config $config) + { + $config->saveIni(); + } } diff --git a/application/forms/Navigation/DashletForm.php b/application/forms/Navigation/DashletForm.php new file mode 100644 index 000000000..8e065a52c --- /dev/null +++ b/application/forms/Navigation/DashletForm.php @@ -0,0 +1,35 @@ +addElement( + 'text', + 'pane', + array( + 'required' => true, + 'label' => $this->translate('Pane'), + 'description' => $this->translate('The name of the dashboard pane in which to display this dashlet') + ) + ); + $this->addElement( + 'text', + 'url', + array( + 'required' => true, + 'label' => $this->translate('Url'), + 'description' => $this->translate( + 'The url to load in the dashlet. For external urls, make sure to prepend' + . ' an appropriate protocol identifier (e.g. http://example.tld)' + ) + ) + ); + } +} diff --git a/application/forms/Navigation/MenuItemForm.php b/application/forms/Navigation/MenuItemForm.php new file mode 100644 index 000000000..583ae4753 --- /dev/null +++ b/application/forms/Navigation/MenuItemForm.php @@ -0,0 +1,31 @@ +getElement('target')->removeMultiOption('_self'); + $this->getElement('target')->removeMultiOption('_next'); + + $parentElement = $this->getParent()->getElement('parent'); + if ($parentElement !== null) { + $parentElement->setDescription($this->translate( + 'The parent menu to assign this menu entry to. Select "None" to make this a main menu entry' + )); + } + } +} diff --git a/application/forms/Navigation/NavigationConfigForm.php b/application/forms/Navigation/NavigationConfigForm.php new file mode 100644 index 000000000..ba1f20d30 --- /dev/null +++ b/application/forms/Navigation/NavigationConfigForm.php @@ -0,0 +1,824 @@ +setName('form_config_navigation'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + /** + * Set the user for whom to manage navigation items + * + * @param User $user + * + * @return $this + */ + public function setUser(User $user) + { + $this->user = $user; + return $this; + } + + /** + * Return the user for whom to manage navigation items + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Set the user's navigation configuration + * + * @param Config $config + * + * @return $this + */ + public function setUserConfig(Config $config) + { + $config->getConfigObject()->setKeyColumn('name'); + $this->userConfig = $config; + return $this; + } + + /** + * Return the user's navigation configuration + * + * @return Config + */ + public function getUserConfig() + { + if ($this->userConfig === null) { + $this->setUserConfig($this->getUser()->loadNavigationConfig()); + } + + return $this->userConfig; + } + + /** + * Set the shared navigation configuration + * + * @param Config $config + * + * @return $this + */ + public function setShareConfig(Config $config) + { + $config->getConfigObject()->setKeyColumn('name'); + $this->shareConfig = $config; + return $this; + } + + /** + * Return the shared navigation configuration + * + * @return Config + */ + public function getShareConfig() + { + return $this->shareConfig; + } + + /** + * Set the available navigation item types + * + * @param array $itemTypes + * + * @return $this + */ + public function setItemTypes(array $itemTypes) + { + $this->itemTypes = $itemTypes; + return $this; + } + + /** + * Return the available navigation item types + * + * @return array + */ + public function getItemTypes() + { + return $this->itemTypes ?: array(); + } + + /** + * Return a list of available parent items for the given type of navigation item + * + * @param string $type + * @param string $owner + * + * @return array + */ + public function listAvailableParents($type, $owner = null) + { + $children = $this->itemToLoad ? $this->getFlattenedChildren($this->itemToLoad) : array(); + + $names = array(); + foreach ($this->getShareConfig() as $sectionName => $sectionConfig) { + if ( + $sectionName !== $this->itemToLoad + && $sectionConfig->type === $type + && $sectionConfig->owner === ($owner ?: $this->getUser()->getUsername()) + && !in_array($sectionName, $children, true) + ) { + $names[] = $sectionName; + } + } + + foreach ($this->getUserConfig() as $sectionName => $sectionConfig) { + if ( + $sectionName !== $this->itemToLoad + && $sectionConfig->type === $type + && !in_array($sectionName, $children, true) + ) { + $names[] = $sectionName; + } + } + + return $names; + } + + /** + * Recursively return all children of the given navigation item + * + * @param string $name + * + * @return array + */ + protected function getFlattenedChildren($name) + { + $config = $this->getConfigForItem($name); + if ($config === null) { + return array(); + } + + $children = array(); + foreach ($config->toArray() as $sectionName => $sectionConfig) { + if (isset($sectionConfig['parent']) && $sectionConfig['parent'] === $name) { + $children[] = $sectionName; + $children = array_merge($children, $this->getFlattenedChildren($sectionName)); + } + } + + return $children; + } + + /** + * Populate the form with the given navigation item's config + * + * @param string $name + * + * @return $this + * + * @throws NotFoundError In case no navigation item with the given name is found + */ + public function load($name) + { + if ($this->getConfigForItem($name) === null) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } + + $this->itemToLoad = $name; + return $this; + } + + /** + * Add a new navigation item + * + * The navigation item to add is identified by the array-key `name'. + * + * @param array $data + * + * @return $this + * + * @throws InvalidArgumentException In case $data does not contain a navigation item name + * @throws IcingaException In case a navigation item with the same name already exists + */ + public function add(array $data) + { + if (! isset($data['name'])) { + throw new InvalidArgumentException('Key \'name\' missing'); + } + + $shared = false; + $config = $this->getUserConfig(); + if ((isset($data['users']) && $data['users']) || (isset($data['groups']) && $data['groups'])) { + if ($this->getUser()->can('application/share/navigation')) { + $data['owner'] = $this->getUser()->getUsername(); + $config = $this->getShareConfig(); + $shared = true; + } else { + unset($data['users']); + unset($data['groups']); + } + } elseif (isset($data['parent']) && $data['parent'] && $this->hasBeenShared($data['parent'])) { + $data['owner'] = $this->getUser()->getUsername(); + $config = $this->getShareConfig(); + $shared = true; + } + + $itemName = $data['name']; + $exists = $config->hasSection($itemName); + if (! $exists) { + if ($shared) { + $exists = $this->getUserConfig()->hasSection($itemName); + } else { + $exists = (bool) $this->getShareConfig() + ->select() + ->where('name', $itemName) + ->where('owner', $this->getUser()->getUsername()) + ->count(); + } + } + + if ($exists) { + throw new IcingaException( + $this->translate('A navigation item with the name "%s" does already exist'), + $itemName + ); + } + + unset($data['name']); + $config->setSection($itemName, $data); + $this->setIniConfig($config); + return $this; + } + + /** + * Edit a navigation item + * + * @param string $name + * @param array $data + * + * @return $this + * + * @throws NotFoundError In case no navigation item with the given name is found + * @throws IcingaException In case a navigation item with the same name already exists + */ + public function edit($name, array $data) + { + $config = $this->getConfigForItem($name); + if ($config === null) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } else { + $itemConfig = $config->getSection($name); + } + + $shared = false; + if ($this->hasBeenShared($name)) { + if (isset($data['parent']) && $data['parent'] + ? !$this->hasBeenShared($data['parent']) + : ((! isset($data['users']) || !$data['users']) && (! isset($data['groups']) || !$data['groups'])) + ) { + // It is shared but shouldn't anymore + $config = $this->unshare($name, isset($data['parent']) ? $data['parent'] : null); + } + } elseif ((isset($data['users']) && $data['users']) || (isset($data['groups']) && $data['groups'])) { + if ($this->getUser()->can('application/share/navigation')) { + // It is not shared yet but should be + $this->secondaryConfig = $config; + $config = $this->getShareConfig(); + $data['owner'] = $this->getUser()->getUsername(); + $shared = true; + } else { + unset($data['users']); + unset($data['groups']); + } + } elseif (isset($data['parent']) && $data['parent'] && $this->hasBeenShared($data['parent'])) { + // Its parent is shared so should it itself + $this->secondaryConfig = $config; + $config = $this->getShareConfig(); + $data['owner'] = $this->getUser()->getUsername(); + $shared = true; + } + + $oldName = null; + if (isset($data['name'])) { + if ($data['name'] !== $name) { + $oldName = $name; + $name = $data['name']; + + $exists = $config->hasSection($name); + if (! $exists) { + $ownerName = $itemConfig->owner ?: $this->getUser()->getUsername(); + if ($shared || $this->hasBeenShared($oldName)) { + if ($ownerName === $this->getUser()->getUsername()) { + $exists = $this->getUserConfig()->hasSection($name); + } else { + $owner = new User($ownerName); + $exists = $owner->loadNavigationConfig()->hasSection($name); + } + } else { + $exists = (bool) $this->getShareConfig() + ->select() + ->where('name', $name) + ->where('owner', $ownerName) + ->count(); + } + } + + if ($exists) { + throw new IcingaException( + $this->translate('A navigation item with the name "%s" does already exist'), + $name + ); + } + } + + unset($data['name']); + } + + $itemConfig->merge($data); + foreach ($itemConfig->toArray() as $k => $v) { + if ($v === null) { + unset($itemConfig->$k); + } + } + + if ($shared) { + // Share all descendant children + foreach ($this->getFlattenedChildren($oldName ?: $name) as $child) { + $childConfig = $this->secondaryConfig->getSection($child); + $this->secondaryConfig->removeSection($child); + $childConfig->owner = $this->getUser()->getUsername(); + $config->setSection($child, $childConfig); + } + } + + if ($oldName) { + // Update the parent name on all direct children + foreach ($config as $sectionConfig) { + if ($sectionConfig->parent === $oldName) { + $sectionConfig->parent = $name; + } + } + + $config->removeSection($oldName); + } + + if ($this->secondaryConfig !== null) { + $this->secondaryConfig->removeSection($oldName ?: $name); + } + + $config->setSection($name, $itemConfig); + $this->setIniConfig($config); + return $this; + } + + /** + * Remove a navigation item + * + * @param string $name + * + * @return ConfigObject The navigation item's config + * + * @throws NotFoundError In case no navigation item with the given name is found + * @throws IcingaException In case the navigation item has still children + */ + public function delete($name) + { + $config = $this->getConfigForItem($name); + if ($config === null) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } + + $children = $this->getFlattenedChildren($name); + if (! empty($children)) { + throw new IcingaException( + $this->translate( + 'Unable to delete navigation item "%s". There' + . ' are other items dependent from it: %s' + ), + $name, + join(', ', $children) + ); + } + + $section = $config->getSection($name); + $config->removeSection($name); + $this->setIniConfig($config); + return $section; + } + + /** + * Unshare the given navigation item + * + * @param string $name + * @param string $parent + * + * @return Config The new config of the given navigation item + * + * @throws NotFoundError In case no navigation item with the given name is found + * @throws IcingaException In case the navigation item has a parent assigned to it + */ + public function unshare($name, $parent = null) + { + $config = $this->getShareConfig(); + if (! $config->hasSection($name)) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } + + $itemConfig = $config->getSection($name); + if ($parent === null) { + $parent = $itemConfig->parent; + } + + if ($parent && $this->hasBeenShared($parent)) { + throw new IcingaException( + $this->translate( + 'Unable to unshare navigation item "%s". It is dependent from item "%s".' + . ' Dependent items can only be unshared by unsharing their parent' + ), + $name, + $parent + ); + } + + $children = $this->getFlattenedChildren($name); + $config->removeSection($name); + $this->secondaryConfig = $config; + + if (! $itemConfig->owner || $itemConfig->owner === $this->getUser()->getUsername()) { + $config = $this->getUserConfig(); + } else { + $owner = new User($itemConfig->owner); + $config = $owner->loadNavigationConfig(); + } + + foreach ($children as $child) { + $childConfig = $this->secondaryConfig->getSection($child); + unset($childConfig->owner); + $this->secondaryConfig->removeSection($child); + $config->setSection($child, $childConfig); + } + + unset($itemConfig->owner); + unset($itemConfig->users); + unset($itemConfig->groups); + + $config->setSection($name, $itemConfig); + $this->setIniConfig($config); + return $config; + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $shared = false; + $itemTypes = $this->getItemTypes(); + $itemType = isset($formData['type']) ? $formData['type'] : key($itemTypes); + $itemForm = $this->getItemForm($itemType); + + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Name'), + 'description' => $this->translate( + 'The name of this navigation item that is used to differentiate it from others' + ) + ) + ); + + if ( + (! $itemForm->requiresParentSelection() || !isset($formData['parent']) || !$formData['parent']) + && $this->getUser()->can('application/share/navigation') + ) { + $checked = isset($formData['shared']) ? null : (isset($formData['users']) || isset($formData['groups'])); + + $this->addElement( + 'checkbox', + 'shared', + array( + 'autosubmit' => true, + 'ignore' => true, + 'value' => $checked, + 'label' => $this->translate('Shared'), + 'description' => $this->translate('Tick this box to share this item with others') + ) + ); + + if ($checked || (isset($formData['shared']) && $formData['shared'])) { + $shared = true; + $this->addElement( + 'text', + 'users', + array( + 'label' => $this->translate('Users'), + 'description' => $this->translate( + 'Comma separated list of usernames to share this item with' + ) + ) + ); + $this->addElement( + 'text', + 'groups', + array( + 'label' => $this->translate('Groups'), + 'description' => $this->translate( + 'Comma separated list of group names to share this item with' + ) + ) + ); + } + } + + $this->addElement( + 'select', + 'type', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Type'), + 'description' => $this->translate('The type of this navigation item'), + 'multiOptions' => $itemTypes + ) + ); + + if (! $shared && $itemForm->requiresParentSelection()) { + if ($this->itemToLoad && $this->hasBeenShared($this->itemToLoad)) { + $itemConfig = $this->getShareConfig()->getSection($this->itemToLoad); + $availableParents = $this->listAvailableParents($itemType, $itemConfig->owner); + } else { + $availableParents = $this->listAvailableParents($itemType); + } + + $this->addElement( + 'select', + 'parent', + array( + 'allowEmpty' => true, + 'autosubmit' => true, + 'label' => $this->translate('Parent'), + 'description' => $this->translate( + 'The parent item to assign this navigation item to. ' + . 'Select "None" to make this a main navigation item' + ), + 'multiOptions' => array_merge( + array('' => $this->translate('None', 'No parent for a navigation item')), + empty($availableParents) ? array() : array_combine($availableParents, $availableParents) + ) + ) + ); + } + + $this->addSubForm($itemForm, 'item_form'); + $itemForm->create($formData); // May require a parent which gets set by addSubForm() + } + + /** + * Populate the configuration of the navigation item to load + */ + public function onRequest() + { + if ($this->itemToLoad) { + $data = $this->getConfigForItem($this->itemToLoad)->getSection($this->itemToLoad)->toArray(); + $data['name'] = $this->itemToLoad; + $this->populate($data); + } + } + + /** + * {@inheritdoc} + */ + public function isValid($formData) + { + if (! parent::isValid($formData)) { + return false; + } + + $valid = true; + if (isset($formData['users']) && $formData['users']) { + $parsedUserRestrictions = array(); + foreach (Auth::getInstance()->getRestrictions('application/share/users') as $userRestriction) { + $parsedUserRestrictions[] = array_map('trim', explode(',', $userRestriction)); + } + + if (! empty($parsedUserRestrictions)) { + $desiredUsers = array_map('trim', explode(',', $formData['users'])); + array_unshift($parsedUserRestrictions, $desiredUsers); + $forbiddenUsers = call_user_func_array('array_diff', $parsedUserRestrictions); + if (! empty($forbiddenUsers)) { + $valid = false; + $this->getElement('users')->addError( + $this->translate(sprintf( + 'You are not permitted to share this navigation item with the following users: %s', + implode(', ', $forbiddenUsers) + )) + ); + } + } + } + + if (isset($formData['groups']) && $formData['groups']) { + $parsedGroupRestrictions = array(); + foreach (Auth::getInstance()->getRestrictions('application/share/groups') as $groupRestriction) { + $parsedGroupRestrictions[] = array_map('trim', explode(',', $groupRestriction)); + } + + if (! empty($parsedGroupRestrictions)) { + $desiredGroups = array_map('trim', explode(',', $formData['groups'])); + array_unshift($parsedGroupRestrictions, $desiredGroups); + $forbiddenGroups = call_user_func_array('array_diff', $parsedGroupRestrictions); + if (! empty($forbiddenGroups)) { + $valid = false; + $this->getElement('groups')->addError( + $this->translate(sprintf( + 'You are not permitted to share this navigation item with the following groups: %s', + implode(', ', $forbiddenGroups) + )) + ); + } + } + } + + return $valid; + } + + /** + * {@inheritdoc} + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues(); + $values = array_merge($values, $values['item_form']); + unset($values['item_form']); + return $values; + } + + /** + * {@inheritdoc} + */ + protected function writeConfig(Config $config) + { + parent::writeConfig($config); + + if ($this->secondaryConfig !== null) { + $this->config = $this->secondaryConfig; // Causes the config being displayed to the user in case of an error + parent::writeConfig($this->secondaryConfig); + } + } + + /** + * Return the navigation configuration the given item is a part of + * + * @param string $name + * + * @return Config|null In case the item is not part of any configuration + */ + protected function getConfigForItem($name) + { + if ($this->getUserConfig()->hasSection($name)) { + return $this->getUserConfig(); + } elseif ($this->getShareConfig()->hasSection($name)) { + if ( + $this->getShareConfig()->get($name, 'owner') === $this->getUser()->getUsername() + || $this->getUser()->can('config/application/navigation') + ) { + return $this->getShareConfig(); + } + } + } + + /** + * Return whether the given navigation item has been shared + * + * @param string $name + * + * @return bool + */ + protected function hasBeenShared($name) + { + return $this->getConfigForItem($name) === $this->getShareConfig(); + } + + /** + * Return the form for the given type of navigation item + * + * @param string $type + * + * @return Form + */ + protected function getItemForm($type) + { + $className = String::cname($type, '-') . 'Form'; + + $form = null; + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + $classPath = 'Icinga\\Module\\' + . ucfirst($module->getName()) + . '\\' + . static::FORM_NS + . '\\' + . $className; + if (class_exists($classPath)) { + $form = new $classPath(); + break; + } + } + + if ($form === null) { + $classPath = 'Icinga\\' . static::FORM_NS . '\\' . $className; + if (class_exists($classPath)) { + $form = new $classPath(); + } + } + + if ($form === null) { + Logger::debug( + 'Failed to find custom navigation item form %s for item %s. Using form NavigationItemForm now', + $className, + $type + ); + + $form = new NavigationItemForm(); + } elseif (! $form instanceof NavigationItemForm) { + throw new ProgrammingError('Class %s must inherit from NavigationItemForm', $classPath); + } + + return $form; + } +} diff --git a/application/forms/Navigation/NavigationItemForm.php b/application/forms/Navigation/NavigationItemForm.php new file mode 100644 index 000000000..0011b044c --- /dev/null +++ b/application/forms/Navigation/NavigationItemForm.php @@ -0,0 +1,74 @@ +requiresParentSelection; + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $this->addElement( + 'select', + 'target', + array( + 'allowEmpty' => true, + 'label' => $this->translate('Target'), + 'description' => $this->translate('The target where to open this navigation item\'s url'), + 'multiOptions' => array( + '_blank' => $this->translate('New Window'), + '_next' => $this->translate('New Column'), + '_main' => $this->translate('Single Column'), + '_self' => $this->translate('Current Column') + ) + ) + ); + + $this->addElement( + 'text', + 'url', + array( + 'allowEmpty' => true, + 'label' => $this->translate('Url'), + 'description' => $this->translate( + 'The url of this navigation item. Leave blank if you only want the' + . ' name being displayed. For external urls, make sure to prepend' + . ' an appropriate protocol identifier (e.g. http://example.tld)' + ) + ) + ); + + $this->addElement( + 'text', + 'icon', + array( + 'allowEmpty' => true, + 'label' => $this->translate('Icon'), + 'description' => $this->translate( + 'The icon of this navigation item. Leave blank if you do not want a icon being displayed' + ) + ) + ); + } +} diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index 63bd459ce..9ebfc2c91 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -28,7 +28,7 @@ class RoleForm extends ConfigForm * * @var array */ - protected $providedRestrictions = array(); + protected $providedRestrictions; /** * {@inheritdoc} @@ -37,6 +37,8 @@ class RoleForm extends ConfigForm { $this->providedPermissions = array( '*' => $this->translate('Allow everything') . ' (*)', + 'application/share/navigation' => $this->translate('Allow to share navigation items') + . ' (application/share/navigation)', 'application/stacktraces' => $this->translate( 'Allow to adjust in the preferences whether to show stacktraces' ) . ' (application/stacktraces)', @@ -48,6 +50,7 @@ class RoleForm extends ConfigForm 'config/application/resources' => 'config/application/resources', 'config/application/userbackend' => 'config/application/userbackend', 'config/application/usergroupbackend' => 'config/application/usergroupbackend', + 'config/application/navigation' => 'config/application/navigation', 'config/authentication/*' => 'config/authentication/*', 'config/authentication/users/*' => 'config/authentication/users/*', 'config/authentication/users/show' => 'config/authentication/users/show', @@ -67,9 +70,23 @@ class RoleForm extends ConfigForm 'config/modules' => 'config/modules' */ ); - - + $helper = new Zend_Form_Element('bogus'); + $this->providedRestrictions = array( + $helper->filterName('application/share/users') => array( + 'name' => 'application/share/users', + 'description' => $this->translate( + 'Restrict which users this role can share items and information with' + ) + ), + $helper->filterName('application/share/groups') => array( + 'name' => 'application/share/groups', + 'description' => $this->translate( + 'Restrict which groups this role can share items and information with' + ) + ) + ); + $mm = Icinga::app()->getModuleManager(); foreach ($mm->listInstalledModules() as $moduleName) { $modulePermission = $mm::MODULE_PERMISSION_NS . $moduleName; diff --git a/application/layouts/scripts/body.phtml b/application/layouts/scripts/body.phtml index 325b1026b..fe1f7bd28 100644 --- a/application/layouts/scripts/body.phtml +++ b/application/layouts/scripts/body.phtml @@ -22,7 +22,7 @@ if ($this->layout()->autorefreshInterval) { isAuthenticated()): ?> qlink( '', - '/dashboard', + 'dashboard', null, array( 'icon' => '../logo_icinga-inv.png', diff --git a/application/layouts/scripts/layout.phtml b/application/layouts/scripts/layout.phtml index efe1aa9cc..b2c14dba0 100644 --- a/application/layouts/scripts/layout.phtml +++ b/application/layouts/scripts/layout.phtml @@ -63,7 +63,7 @@ $innerLayoutScript = $this->layout()->innerLayout . '.phtml'; diff --git a/application/layouts/scripts/parts/navigation.phtml b/application/layouts/scripts/parts/navigation.phtml index 92273e4b4..78d399810 100644 --- a/application/layouts/scripts/parts/navigation.phtml +++ b/application/layouts/scripts/parts/navigation.phtml @@ -1,8 +1,6 @@ auth()->isAuthenticated()) { @@ -27,10 +25,7 @@ if (! $this->auth()->isAuthenticated()) { 'layout/menu.phtml', 'default', array( - 'menuRenderer' => new MenuRenderer( - Menu::load(), - Url::fromRequest()->without('renderLayout')->getRelativeUrl() - ) + 'menuRenderer' => Icinga::app()->getMenu()->getRenderer() ) ) ?> diff --git a/application/views/scripts/config/resource.phtml b/application/views/scripts/config/resource.phtml index 740e548d9..e431d7c33 100644 --- a/application/views/scripts/config/resource.phtml +++ b/application/views/scripts/config/resource.phtml @@ -2,7 +2,7 @@
- + icon('plus'); ?> translate('Create A New Resource'); ?> diff --git a/application/views/scripts/config/userbackend/reorder.phtml b/application/views/scripts/config/userbackend/reorder.phtml index 08b5f19be..174840d23 100644 --- a/application/views/scripts/config/userbackend/reorder.phtml +++ b/application/views/scripts/config/userbackend/reorder.phtml @@ -2,7 +2,7 @@
- + icon('plus'); ?>translate('Create A New User Backend'); ?>
diff --git a/application/views/scripts/layout/menu.phtml b/application/views/scripts/layout/menu.phtml index ee2ab0081..36b7cbbdb 100644 --- a/application/views/scripts/layout/menu.phtml +++ b/application/views/scripts/layout/menu.phtml @@ -12,8 +12,5 @@ if ($searchDashboard->search('dummy')->getPane('search')->hasDashlets()): ?> autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> - - + +setHeading(t('Navigation'))->setElementTag('nav'); ?> \ No newline at end of file diff --git a/application/views/scripts/navigation/form.phtml b/application/views/scripts/navigation/form.phtml new file mode 100644 index 000000000..cbf06590d --- /dev/null +++ b/application/views/scripts/navigation/form.phtml @@ -0,0 +1,6 @@ +
+ showOnlyCloseButton(); ?> +
+
+ +
\ No newline at end of file diff --git a/application/views/scripts/navigation/index.phtml b/application/views/scripts/navigation/index.phtml new file mode 100644 index 000000000..7c81ba5a8 --- /dev/null +++ b/application/views/scripts/navigation/index.phtml @@ -0,0 +1,53 @@ +compact): ?> +
+ tabs; ?> + sortBox; ?> + limiter; ?> + paginator; ?> + filterEditor; ?> +
+ +
+ + icon('plus'); ?> translate('Create A New Navigation Item'); ?> + + +

translate('You did not create any navigation item yet'); ?>

+ +
+ + + + + + + + $item): ?> + + + + + + + + +
translate('Navigation'); ?>translate('Type'); ?>translate('Shared'); ?>translate('Remove'); ?>
qlink( + $name, + 'navigation/edit', + array('name' => $name), + array( + 'title' => sprintf($this->translate('Edit navigation item %s'), $name) + ) + ); ?>type && isset($types[$item->type]) + ? $this->escape($types[$item->type]) + : $this->escape($this->translate('Unknown')); ?>owner ? $this->translate('Yes') : $this->translate('No'); ?>qlink( + '', + 'navigation/remove', + array('name' => $name), + array( + 'icon' => 'trash', + 'title' => sprintf($this->translate('Remove navigation item %s'), $name) + ) + ); ?>
+ +
\ No newline at end of file diff --git a/application/views/scripts/navigation/shared.phtml b/application/views/scripts/navigation/shared.phtml new file mode 100644 index 000000000..939249f84 --- /dev/null +++ b/application/views/scripts/navigation/shared.phtml @@ -0,0 +1,58 @@ +compact): ?> +
+ tabs; ?> + sortBox; ?> + limiter; ?> + paginator; ?> + filterEditor; ?> +
+ +
+ +

translate('There are currently no navigation items being shared'); ?>

+ + + + + + + + + + $item): ?> + + + + + parent): ?> + + + + + + + +
translate('Shared Navigation'); ?>translate('Type'); ?>translate('Owner'); ?>translate('Unshare'); ?>
qlink( + $name, + 'navigation/edit', + array( + 'name' => $name, + 'referrer' => 'shared' + ), + array( + 'title' => sprintf($this->translate('Edit shared navigation item %s'), $name) + ) + ); ?>type && isset($types[$item->type]) + ? $this->escape($types[$item->type]) + : $this->escape($this->translate('Unknown')); ?>escape($item->owner); ?>icon( + 'block', + sprintf( + $this->translate( + 'This is a child of the navigation item %1$s. You can' + . ' only unshare this item by unsharing %1$s' + ), + $item->parent + ) + ); ?>setDefault('name', $name); ?>
+ +
\ No newline at end of file diff --git a/doc/installation.md b/doc/installation.md index e3e00a93e..a18b654e6 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -301,3 +301,8 @@ The first release candidate of Icinga Web 2 introduces the following non-backwar * The **instances.ini** configuration file provided by the monitoring module has been renamed to **commandtransports.ini**. The content and location of the file remains unchanged. + +* The location of a user's preferences has been changed from + **/preferences/.ini** to + **/preferences//config.ini**. + The content of the file remains unchanged. diff --git a/library/Icinga/Application/Modules/DashboardContainer.php b/library/Icinga/Application/Modules/DashboardContainer.php new file mode 100644 index 000000000..dac3b3ba5 --- /dev/null +++ b/library/Icinga/Application/Modules/DashboardContainer.php @@ -0,0 +1,54 @@ +dashlets = $dashlets; + return $this; + } + + /** + * Return this dashboard's dashlets + * + * @return array + */ + public function getDashlets() + { + return $this->dashlets ?: array(); + } + + /** + * Add a new dashlet + * + * @param string $name + * @param string $url + * + * @return $this + */ + public function add($name, $url) + { + $this->dashlets[$name] = $url; + return $this; + } +} diff --git a/library/Icinga/Application/Modules/MenuItemContainer.php b/library/Icinga/Application/Modules/MenuItemContainer.php new file mode 100644 index 000000000..2d9f176f4 --- /dev/null +++ b/library/Icinga/Application/Modules/MenuItemContainer.php @@ -0,0 +1,55 @@ +children = $children; + return $this; + } + + /** + * Return this menu item's children + * + * @return array + */ + public function getChildren() + { + return $this->children ?: array(); + } + + /** + * Add a new sub menu + * + * @param string $name + * @param array $properties + * + * @return MenuItemContainer The newly added sub menu + */ + public function add($name, array $properties = array()) + { + $child = new MenuItemContainer($name, $properties); + $this->children[] = $child; + return $child; + } +} diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php index 61256c7d5..ebbfc3d98 100644 --- a/library/Icinga/Application/Modules/Module.php +++ b/library/Icinga/Application/Modules/Module.php @@ -11,7 +11,8 @@ use Icinga\Application\ApplicationBootstrap; use Icinga\Application\Config; use Icinga\Application\Icinga; use Icinga\Application\Logger; -use Icinga\Data\ConfigObject; +use Icinga\Application\Modules\DashboardContainer; +use Icinga\Application\Modules\MenuItemContainer; use Icinga\Exception\IcingaException; use Icinga\Exception\ProgrammingError; use Icinga\Module\Setup\SetupWizard; @@ -19,9 +20,8 @@ use Icinga\Util\File; use Icinga\Util\Translator; use Icinga\Web\Controller\Dispatcher; use Icinga\Web\Hook; -use Icinga\Web\Menu; +use Icinga\Web\Navigation\Navigation; use Icinga\Web\Widget; -use Icinga\Web\Widget\Dashboard\Pane; /** * Module handling @@ -189,7 +189,7 @@ class Module /** * A set of menu elements * - * @var Menu[] + * @var MenuItemContainer[] */ protected $menuItems = array(); @@ -221,6 +221,13 @@ class Module */ protected $userGroupBackends = array(); + /** + * This module's configurable navigation items + * + * @var array + */ + protected $navigationItems = array(); + /** * Create a new module object * @@ -277,38 +284,98 @@ class Module } /** - * Get all pane items + * Return this module's dashboard * - * @return array + * @return Navigation */ - public function getPaneItems() + public function getDashboard() { $this->launchConfigScript(); - return $this->paneItems; + return $this->createDashboard($this->paneItems); } /** - * Add a pane to dashboard + * Create and return a new navigation for the given dashboard panes * - * @param string $name + * @param DashboardContainer[] $panes * - * @return Pane + * @return Navigation */ - protected function dashboard($name) + public function createDashboard(array $panes) { - $this->paneItems[$name] = new Pane($name); + $navigation = new Navigation(); + foreach ($panes as $pane) { + /** @var DashboardContainer $pane */ + $dashlets = array(); + foreach ($pane->getDashlets() as $dashletName => $dashletUrl) { + $dashlets[$this->translate($dashletName)] = $dashletUrl; + } + + $navigation->addItem( + $pane->getName(), + array_merge( + $pane->getProperties(), + array( + 'label' => $this->translate($pane->getName()), + 'type' => 'dashboard-pane', + 'dashlets' => $dashlets + ) + ) + ); + } + + return $navigation; + } + + /** + * Add or get a dashboard pane + * + * @param string $name + * @param array $properties + * + * @return DashboardContainer + */ + protected function dashboard($name, array $properties = array()) + { + if (array_key_exists($name, $this->paneItems)) { + $this->paneItems[$name]->setProperties($properties); + } else { + $this->paneItems[$name] = new DashboardContainer($name, $properties); + } + return $this->paneItems[$name]; } /** - * Get all menu items + * Return this module's menu * - * @return array + * @return Navigation */ - public function getMenuItems() + public function getMenu() { $this->launchConfigScript(); - return $this->menuItems; + return $this->createMenu($this->menuItems); + } + + /** + * Create and return a new navigation for the given menu items + * + * @param MenuItemContainer[] $items + * + * @return Navigation + */ + private function createMenu(array $items) + { + $navigation = new Navigation(); + foreach ($items as $item) { + /** @var MenuItemContainer $item */ + $navigationItem = $navigation->createItem($item->getName(), $item->getProperties()); + $navigationItem->setChildren($this->createMenu($item->getChildren())); + $navigationItem->setLabel($this->translate($item->getName())); + $navigation->addItem($navigationItem); + } + + return $navigation; } /** @@ -317,14 +384,14 @@ class Module * @param string $name * @param array $properties * - * @return Menu + * @return MenuItemContainer */ protected function menuSection($name, array $properties = array()) { if (array_key_exists($name, $this->menuItems)) { $this->menuItems[$name]->setProperties($properties); } else { - $this->menuItems[$name] = new Menu($name, new ConfigObject($properties)); + $this->menuItems[$name] = new MenuItemContainer($name, $properties); } return $this->menuItems[$name]; @@ -831,6 +898,17 @@ class Module return $this->userGroupBackends; } + /** + * Return this module's configurable navigation items + * + * @return array + */ + public function getNavigationItems() + { + $this->launchConfigScript(); + return $this->navigationItems; + } + /** * Provide a named permission * @@ -935,6 +1013,20 @@ class Module return $this; } + /** + * Provide a new type of configurable navigation item with a optional label + * + * @param string $type + * @param string $label + * + * @return $this + */ + protected function provideNavigationItem($type, $label = null) + { + $this->navigationItems[$type] = $label ?: $type; + return $this; + } + /** * Register module namespaces on our class loader * diff --git a/library/Icinga/Application/Modules/NavigationItemContainer.php b/library/Icinga/Application/Modules/NavigationItemContainer.php new file mode 100644 index 000000000..b59d52f60 --- /dev/null +++ b/library/Icinga/Application/Modules/NavigationItemContainer.php @@ -0,0 +1,117 @@ +name = $name; + $this->properties = $properties; + } + + /** + * Set this menu item's name + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Return this menu item's name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set this menu item's properties + * + * @param array $properties + * + * @return $this + */ + public function setProperties(array $properties) + { + $this->properties = $properties; + return $this; + } + + /** + * Return this menu item's properties + * + * @return array + */ + public function getProperties() + { + return $this->properties ?: array(); + } + + /** + * Allow dynamic setters and getters for properties + * + * @param string $name + * @param array $arguments + * + * @return mixed + * + * @throws ProgrammingError In case the called method is not supported + */ + public function __call($name, $arguments) + { + if (method_exists($this, $name)) { + return call_user_method_array($name, $this, $arguments); + } + + $type = substr($name, 0, 3); + if ($type !== 'set' && $type !== 'get') { + throw new ProgrammingError( + 'Dynamic method %s is not supported. Only getters (get*) and setters (set*) are.', + $name + ); + } + + $propertyName = strtolower(join('_', preg_split('~(?=[A-Z])~', lcfirst(substr($name, 3))))); + if ($type === 'set') { + $this->properties[$propertyName] = $arguments[0]; + return $this; + } else { // $type === 'get' + return array_key_exists($propertyName, $this->properties) ? $this->properties[$propertyName] : null; + } + } +} diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php index 47aaaef3f..538afb029 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -16,6 +16,7 @@ use Icinga\User; use Icinga\Util\TimezoneDetect; use Icinga\Util\Translator; use Icinga\Web\Controller\Dispatcher; +use Icinga\Web\Navigation\Navigation; use Icinga\Web\Notification; use Icinga\Web\Session; use Icinga\Web\Session\Session as BaseSession; @@ -139,6 +140,204 @@ class Web extends EmbeddedWeb return $this->viewRenderer; } + private function hasAccessToSharedNavigationItem(& $config) + { + // TODO: Provide a more sophisticated solution + + if (isset($config['owner']) && $config['owner'] === $this->user->getUsername()) { + unset($config['owner']); + return true; + } + + if (isset($config['users'])) { + $users = array_map('trim', explode(',', strtolower($config['users']))); + if (in_array($this->user->getUsername(), $users, true)) { + unset($config['users']); + return true; + } + } + + if (isset($config['groups'])) { + $groups = array_map('trim', explode(',', strtolower($config['groups']))); + $userGroups = array_map('strtolower', $this->user->getGroups()); + $matches = array_intersect($userGroups, $groups); + if (! empty($matches)) { + unset($config['groups']); + return true; + } + } + + return false; + } + + /** + * Load and return the shared navigation of the given type + * + * @param string $type + * + * @return Navigation + */ + public function getSharedNavigation($type) + { + $config = Config::app('navigation')->getConfigObject(); + $config->setKeyColumn('name'); + + if ($type === 'dashboard-pane') { + $panes = array(); + foreach ($config->select()->where('type', 'dashlet') as $dashletName => $dashletConfig) { + if ($this->hasAccessToSharedNavigationItem($dashletConfig)) { + // TODO: Throw ConfigurationError if pane or url is missing + $panes[$dashletConfig->pane][$dashletName] = $dashletConfig->url; + } + } + + $navigation = new Navigation(); + foreach ($panes as $paneName => $dashlets) { + $navigation->addItem( + $paneName, + array( + 'type' => 'dashboard-pane', + 'dashlets' => $dashlets + ) + ); + } + } else { + $items = array(); + foreach ($config->select()->where('type', $type) as $name => $typeConfig) { + if ($this->hasAccessToSharedNavigationItem($typeConfig)) { + $items[$name] = $typeConfig; + } + } + + $navigation = Navigation::fromConfig($items); + } + + return $navigation; + } + + /** + * Return the app's menu + * + * @return Navigation + */ + public function getMenu() + { + if ($this->user !== null) { + $menu = array( + 'dashboard' => array( + 'label' => t('Dashboard'), + 'url' => 'dashboard', + 'icon' => 'dashboard', + 'priority' => 10 + ), + 'system' => array( + 'label' => t('System'), + 'icon' => 'services', + 'priority' => 700, + 'renderer' => array( + 'SummaryNavigationItemRenderer', + 'state' => 'critical' + ), + 'children' => array( + 'about' => array( + 'label' => t('About'), + 'url' => 'about', + 'priority' => 701 + ) + ) + ), + 'configuration' => array( + 'label' => t('Configuration'), + 'icon' => 'wrench', + 'permission' => 'config/*', + 'priority' => 800, + 'children' => array( + 'application' => array( + 'label' => t('Application'), + 'url' => 'config/general', + 'permission' => 'config/application/*', + 'priority' => 810 + ), + 'navigation' => array( + 'label' => t('Shared Navigation'), + 'url' => 'navigation/shared', + 'permission' => 'config/application/navigation', + 'priority' => 820, + ), + 'authentication' => array( + 'label' => t('Authentication'), + 'url' => 'config/userbackend', + 'permission' => 'config/authentication/*', + 'priority' => 830 + ), + 'roles' => array( + 'label' => t('Roles'), + 'url' => 'role/list', + 'permission' => 'config/authentication/roles/show', + 'priority' => 840 + ), + 'users' => array( + 'label' => t('Users'), + 'url' => 'user/list', + 'permission' => 'config/authentication/users/show', + 'priority' => 850 + ), + 'groups' => array( + 'label' => t('Usergroups'), + 'url' => 'group/list', + 'permission' => 'config/authentication/groups/show', + 'priority' => 860 + ), + 'modules' => array( + 'label' => t('Modules'), + 'url' => 'config/modules', + 'permission' => 'config/modules', + 'priority' => 890 + ) + ) + ), + 'user' => array( + 'label' => $this->user->getUsername(), + 'icon' => 'user', + 'priority' => 900, + 'children' => array( + 'preferences' => array( + 'label' => t('Preferences'), + 'url' => 'preference', + 'priority' => 910 + ), + 'navigation' => array( + 'label' => t('Navigation'), + 'url' => 'navigation', + 'priority' => 920 + ), + 'logout' => array( + 'label' => t('Logout'), + 'url' => 'authentication/logout', + 'priority' => 990, + 'renderer' => array( + 'NavigationItemRenderer', + 'target' => '_self' + ) + ) + ) + ) + ); + + if (Logger::writesToFile()) { + $menu['system']['children']['application_log'] = array( + 'label' => t('Application Log'), + 'url' => 'list/applicationlog', + 'priority' => 710 + ); + } + } else { + $menu = array(); + } + + return Navigation::fromArray($menu)->load('menu-item'); + } + /** * Dispatch public interface */ diff --git a/library/Icinga/Application/functions.php b/library/Icinga/Application/functions.php index 3ae0c7ba9..0ec120a8c 100644 --- a/library/Icinga/Application/functions.php +++ b/library/Icinga/Application/functions.php @@ -3,6 +3,23 @@ use Icinga\Util\Translator; + +/** + * No-op translate + * + * Supposed to be used for marking a string as available for translation without actually translating it immediately. + * The returned string is the one given in the input. This does only work with the standard gettext macros t() and mt(). + * + * @param string $messageId + * + * @return string + */ +function N_($messageId) +{ + return $messageId; +} + + if (extension_loaded('gettext')) { /** diff --git a/library/Icinga/Data/Filter/FilterExpression.php b/library/Icinga/Data/Filter/FilterExpression.php index 8ef1e5076..ca5866a6a 100644 --- a/library/Icinga/Data/Filter/FilterExpression.php +++ b/library/Icinga/Data/Filter/FilterExpression.php @@ -97,18 +97,41 @@ class FilterExpression extends Filter public function matches($row) { + if (! isset($row->{$this->column})) { + // TODO: REALLY? Exception? + return false; + } + if (is_array($this->expression)) { return in_array($row->{$this->column}, $this->expression); - } elseif (strpos($this->expression, '*') === false) { - return (string) $row->{$this->column} === (string) $this->expression; - } else { - $parts = preg_split('~\*~', $this->expression); - foreach ($parts as & $part) { - $part = preg_quote($part); - } - $pattern = '/^' . implode('.*', $parts) . '$/'; - return (bool) preg_match($pattern, $row->{$this->column}); } + + $expression = (string) $this->expression; + if (strpos($expression, '*') === false) { + if (is_array($row->{$this->column})) { + return in_array($expression, $row->{$this->column}); + } + + return (string) $row->{$this->column} === $expression; + } + + $parts = array(); + foreach (preg_split('~\*~', $expression) as $part) { + $parts[] = preg_quote($part); + } + $pattern = '/^' . implode('.*', $parts) . '$/'; + + if (is_array($row->{$this->column})) { + foreach ($row->{$this->column} as $candidate) { + if (preg_match($pattern, $candidate)) { + return true; + } + } + + return false; + } + + return (bool) preg_match($pattern, $row->{$this->column}); } public function andFilter(Filter $filter) diff --git a/library/Icinga/Data/Filter/FilterMatch.php b/library/Icinga/Data/Filter/FilterMatch.php index a5c058b91..9f25ac88d 100644 --- a/library/Icinga/Data/Filter/FilterMatch.php +++ b/library/Icinga/Data/Filter/FilterMatch.php @@ -5,21 +5,4 @@ namespace Icinga\Data\Filter; class FilterMatch extends FilterExpression { - public function matches($row) - { - if (! isset($row->{$this->column})) { - // TODO: REALLY? Exception? - return false; - } - $expression = (string) $this->expression; - if (strpos($expression, '*') === false) { - return (string) $row->{$this->column} === $expression; - } else { - $parts = array(); - foreach (preg_split('/\*/', $expression) as $part) { - $parts[] = preg_quote($part); - } - return preg_match('/^' . implode('.*', $parts) . '$/', $row->{$this->column}); - } - } } diff --git a/library/Icinga/Data/Filter/FilterMatchNot.php b/library/Icinga/Data/Filter/FilterMatchNot.php index fe9fbb96a..a2a78a4ed 100644 --- a/library/Icinga/Data/Filter/FilterMatchNot.php +++ b/library/Icinga/Data/Filter/FilterMatchNot.php @@ -7,15 +7,6 @@ class FilterMatchNot extends FilterExpression { public function matches($row) { - $expression = (string) $this->expression; - if (strpos($expression, '*') === false) { - return (string) $row->{$this->column} !== $expression; - } else { - $parts = array(); - foreach (preg_split('/\*/', $expression) as $part) { - $parts[] = preg_quote($part); - } - return ! preg_match('/^' . implode('.*', $parts) . '$/', $row->{$this->column}); - } + return !parent::matches($row); } } diff --git a/library/Icinga/User.php b/library/Icinga/User.php index 11d8177d1..114e59755 100644 --- a/library/Icinga/User.php +++ b/library/Icinga/User.php @@ -5,7 +5,9 @@ namespace Icinga; use DateTimeZone; use InvalidArgumentException; +use Icinga\Application\Config; use Icinga\User\Preferences; +use Icinga\Web\Navigation\Navigation; /** * This class represents an authorized user @@ -476,4 +478,56 @@ class User return false; } + + /** + * Load and return this user's navigation configuration + * + * @return Config + */ + public function loadNavigationConfig() + { + return Config::fromIni( + Config::resolvePath('preferences') + . DIRECTORY_SEPARATOR + . $this->getUsername() + . DIRECTORY_SEPARATOR + . 'navigation.ini' + ); + } + + /** + * Load and return this user's configured navigation of the given type + * + * @param string $type + * + * @return Navigation + */ + public function getNavigation($type) + { + $config = $this->loadNavigationConfig(); + $config->getConfigObject()->setKeyColumn('name'); + + if ($type === 'dashboard-pane') { + $panes = array(); + foreach ($config->select()->where('type', 'dashlet') as $dashletName => $dashletConfig) { + // TODO: Throw ConfigurationError if pane or url is missing + $panes[$dashletConfig->pane][$dashletName] = $dashletConfig->url; + } + + $navigation = new Navigation(); + foreach ($panes as $paneName => $dashlets) { + $navigation->addItem( + $paneName, + array( + 'type' => 'dashboard-pane', + 'dashlets' => $dashlets + ) + ); + } + } else { + $navigation = Navigation::fromConfig($config->select()->where('type', $type)); + } + + return $navigation; + } } diff --git a/library/Icinga/User/Preferences/Store/IniStore.php b/library/Icinga/User/Preferences/Store/IniStore.php index e5dbe4286..7af55ca74 100644 --- a/library/Icinga/User/Preferences/Store/IniStore.php +++ b/library/Icinga/User/Preferences/Store/IniStore.php @@ -35,7 +35,7 @@ class IniStore extends PreferencesStore protected function init() { $this->preferencesFile = sprintf( - '%s/%s.ini', + '%s/%s/config.ini', $this->getStoreConfig()->location, strtolower($this->getUser()->getUsername()) ); diff --git a/library/Icinga/Util/String.php b/library/Icinga/Util/String.php index 712114ee2..3ab09bf1c 100644 --- a/library/Icinga/Util/String.php +++ b/library/Icinga/Util/String.php @@ -24,7 +24,7 @@ class String /** * Uppercase the first character of each word in a string * - * Converts 'first_name' to 'firstName' for example. + * Converts 'first_name' to 'FirstName' for example. * * @param string $name * @param string $separator Word separator diff --git a/library/Icinga/Web/Form.php b/library/Icinga/Web/Form.php index 1e42e8ba6..aa4ab2cbf 100644 --- a/library/Icinga/Web/Form.php +++ b/library/Icinga/Web/Form.php @@ -65,6 +65,15 @@ class Form extends Zend_Form */ protected $created = false; + /** + * This form's parent + * + * Gets automatically set upon calling addSubForm(). + * + * @var Form + */ + protected $_parent; + /** * Whether the form is an API target * @@ -243,6 +252,29 @@ class Form extends Zend_Form parent::__construct($options); } + /** + * Set this form's parent + * + * @param Form $form + * + * @return $this + */ + public function setParent(Form $form) + { + $this->_parent = $form; + return $this; + } + + /** + * Return this form's parent + * + * @return Form + */ + public function getParent() + { + return $this->_parent; + } + /** * Set a callback that is called instead of this form's onSuccess method * @@ -844,6 +876,7 @@ class Form extends Zend_Form $form->setSubmitLabel(''); $form->setTokenDisabled(); $form->setUidDisabled(); + $form->setParent($this); } if ($name === null) { diff --git a/library/Icinga/Web/Navigation/DashboardPane.php b/library/Icinga/Web/Navigation/DashboardPane.php new file mode 100644 index 000000000..3bf6d0992 --- /dev/null +++ b/library/Icinga/Web/Navigation/DashboardPane.php @@ -0,0 +1,73 @@ +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) { + ksort($this->dashlets); + } + + return $this->dashlets; + } + + /** + * {@inheritdoc} + */ + public function init() + { + $this->setUrl(Url::fromPath('dashboard', array('pane' => $this->getName()))); + } + + /** + * {@inheritdoc} + */ + public function merge(NavigationItem $item) + { + parent::merge($item); + + $this->setDashlets(array_merge( + $this->getDashlets(false), + $item->getDashlets(false) + )); + } +} diff --git a/library/Icinga/Web/Navigation/DropdownItem.php b/library/Icinga/Web/Navigation/DropdownItem.php new file mode 100644 index 000000000..98f153500 --- /dev/null +++ b/library/Icinga/Web/Navigation/DropdownItem.php @@ -0,0 +1,21 @@ +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 000000000..9cd3423a3 --- /dev/null +++ b/library/Icinga/Web/Navigation/Navigation.php @@ -0,0 +1,552 @@ +items[$offset]); + } + + /** + * {@inheritdoc} + */ + public function offsetGet($offset) + { + return isset($this->items[$offset]) ? $this->items[$offset] : null; + } + + /** + * {@inheritdoc} + */ + public function offsetSet($offset, $value) + { + $this->items[$offset] = $value; + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($offset) + { + unset($this->items[$offset]); + } + + /** + * {@inheritdoc} + */ + public function count() + { + return count($this->items); + } + + /** + * {@inheritdoc} + */ + public function getIterator() + { + $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']) ? String::cname($properties['type'], '-') : 'NavigationItem'; + if (! empty(static::$types) && isset(static::$types[$itemType])) { + return new static::$types[$itemType]($name, $properties); + } + + $item = 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) { + 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 + */ + protected 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+$~', $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) + { + // Shareables + $this->merge(Icinga::app()->getSharedNavigation($type)); + + // User Preferences + $user = Auth::getInstance()->getUser(); + $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; + } + + /** + * 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($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 000000000..ba6197ce4 --- /dev/null +++ b/library/Icinga/Web/Navigation/NavigationItem.php @@ -0,0 +1,791 @@ +setName($name); + $this->priority = 100; + $this->children = new Navigation(); + + if (! empty($properties)) { + $this->setProperties($properties); + } + + $this->init(); + } + + /** + * Initialize this NavigationItem + */ + public function init() + { + + } + + /** + * {@inheritdoc} + */ + public function getIterator() + { + 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 this item's priority + * + * @return int + */ + public function getPriority() + { + return $this->priority; + } + + /** + * 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 ?: $this->getName(); + } + + /** + * Set this item's label + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = $label; + 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('#')); + } + + 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($url); + } elseif (! $url instanceof Url) { + 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 ($item->getActive()) { + $this->setActive(); + } + + if (! $this->getIcon()) { + $this->setIcon($item->getIcon()); + } + + if ($this->getLabel() === $this->getName()) { + $this->setLabel($item->getLabel()); + } + + 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; + 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 true; + } + + 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() + { + return $this->getRenderer()->setItem($this)->render(); + } + + /** + * Return this item 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/Renderer/BadgeNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php new file mode 100644 index 000000000..fa0d82324 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php @@ -0,0 +1,118 @@ +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) + { + return $this->renderBadge() . parent::render($item); + } + + /** + * Render the badge + * + * @return string + */ + protected function renderBadge() + { + if (($count = $this->getCount()) > 0) { + return sprintf( + '
%s
', + $this->view()->escape($this->getTitle()), + $this->view()->escape($this->getState()), + $count + ); + } + + return ''; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php new file mode 100644 index 000000000..7e7a0fd55 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php @@ -0,0 +1,197 @@ +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' . String::cname($name); + if (method_exists($this, $setter)) { + $this->$setter($value); + } + } + } + + /** + * 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; + } + + /** + * 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->view()->escape($item->getLabel()); + if (($icon = $item->getIcon()) !== null) { + $label = $this->view()->icon($icon) . $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( + '%s', + $this->view()->propertiesToString($item->getAttributes()), + $url, + $this->renderTargetAttribute(), + $label + ); + } else { + $content = sprintf( + '<%1$s%2$s>%3$s', + $item::LINK_ALTERNATIVE, + $this->view()->propertiesToString($item->getAttributes()), + $label + ); + } + + 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) { + 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 000000000..8b5caf581 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php @@ -0,0 +1,367 @@ +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; + } + + /** + * {@inheritdoc} + */ + public function getChildren() + { + return new static($this->current()->getChildren(), $this->skipOuterElement); + } + + /** + * {@inheritdoc} + */ + public function hasChildren() + { + return $this->current()->hasChildren(); + } + + /** + * {@inheritdoc} + * + * @return NavigationItem + */ + public function current() + { + return $this->iterator->current(); + } + + /** + * {@inheritdoc} + */ + public function key() + { + return $this->iterator->key(); + } + + /** + * {@inheritdoc} + */ + public function next() + { + $this->iterator->next(); + } + + /** + * {@inheritdoc} + */ + public function rewind() + { + $this->iterator->rewind(); + if (! $this->skipOuterElement) { + $this->content[] = $this->beginMarkup(); + } + } + + /** + * {@inheritdoc} + */ + public function valid() + { + $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( + '%2$s', + 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[] = 'getElementTag() . '>'; + return join("\n", $content); + } + + /** + * Return the opening markup for multiple navigation items + * + * @return string + */ + public function beginChildrenMarkup() + { + $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; + } + + return '
    '; + } + + /** + * Return the closing markup for multiple navigation items + * + * @return string + */ + public function endChildrenMarkup() + { + return '
'; + } + + /** + * Return the opening markup for the given navigation item + * + * @param NavigationItem $item + * + * @return string + */ + public function beginItemMarkup(NavigationItem $item) + { + $cssClass = array(static::CSS_CLASS_ITEM); + + if ($item->hasChildren() && $item->getChildren()->getLayout() === Navigation::LAYOUT_DROPDOWN) { + $cssClass[] = static::CSS_CLASS_DROPDOWN; + $item + ->setAttribute('class', static::CSS_CLASS_DROPDOWN_TOGGLE) + ->setIcon(static::DROPDOWN_TOGGLE_ICON) + ->setUrl('#'); + } + + if ($item->getActive()) { + $cssClass[] = static::CSS_CLASS_ACTIVE; + } + + $content = sprintf( + '
  • ', + $this->view()->escape($item->getUniqueName()), + join(' ', $cssClass) + ); + return $content; + } + + /** + * Return the closing markup for a navigation item + * + * @return string + */ + public function endItemMarkup() + { + return '
  • '; + } + + /** + * {@inheritdoc} + */ + public function render() + { + foreach ($this as $item) { + /** @var NavigationItem $item */ + if ($item->shouldRender()) { + $this->content[] = $this->beginItemMarkup($item); + $this->content[] = $item->render(); + $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 000000000..0f0e5acf1 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php @@ -0,0 +1,135 @@ +content = array(); + parent::__construct( + new NavigationRenderer($navigation, true), + RecursiveIteratorIterator::SELF_FIRST + ); + } + + /** + * {@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(); + } + + /** + * {@inheritdoc} + */ + public function beginIteration() + { + $this->content[] = $this->getInnerIterator()->beginMarkup(); + } + + /** + * {@inheritdoc} + */ + public function endIteration() + { + $this->content[] = $this->getInnerIterator()->endMarkup(); + } + + /** + * {@inheritdoc} + */ + public function beginChildren() + { + $this->content[] = $this->getInnerIterator()->beginChildrenMarkup(); + } + + /** + * {@inheritdoc} + */ + public function endChildren() + { + $this->content[] = $this->getInnerIterator()->endChildrenMarkup(); + } + + /** + * {@inheritdoc} + */ + public function render() + { + foreach ($this as $item) { + /** @var NavigationItem $item */ + if ($item->shouldRender()) { + $this->content[] = $this->getInnerIterator()->beginItemMarkup($item); + $this->content[] = $item->render(); + 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 000000000..a1af94f59 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php @@ -0,0 +1,46 @@ +getItem()->getChildren() as $child) { + $renderer = $child->getRenderer(); + if ($renderer instanceof BadgeNavigationItemRenderer) { + if ($renderer->getState() === $this->getState()) { + $this->titles[] = $renderer->getTitle(); + $count += $renderer->getCount(); + } + } + } + + return $count; + } + + /** + * {@inheritdoc} + */ + public function getTitle() + { + return !empty($this->titles) ? join(', ', $this->titles) : ''; + } +} diff --git a/library/Icinga/Web/Url.php b/library/Icinga/Web/Url.php index 237c8988a..aa7f652a3 100644 --- a/library/Icinga/Web/Url.php +++ b/library/Icinga/Web/Url.php @@ -13,12 +13,17 @@ use Icinga\Data\Filter\Filter; * returns Urls reflecting all changes made to the url and to the parameters. * * Direct instantiation is prohibited and should be done either with @see Url::fromRequest() or - * @see Url::fromUrlString() - * - * Currently, protocol, host and port are ignored and will be implemented when required + * @see Url::fromPath() */ class Url { + /** + * Whether this url points to an external resource + * + * @var bool + */ + protected $external; + /** * An array of all parameters stored in this Url * @@ -41,12 +46,11 @@ class Url protected $path = ''; /** - * The baseUrl that will be appended to @see Url::$path in order to - * create an absolute Url + * The baseUrl that will be appended to @see Url::$path * * @var string */ - protected $baseUrl = '/'; + protected $baseUrl = ''; protected function __construct() { @@ -124,7 +128,7 @@ class Url $request = self::getRequest(); } - if (!is_string($url)) { + if (! is_string($url)) { throw new ProgrammingError( 'url "%s" is not a string', $url @@ -132,17 +136,43 @@ class Url } $urlObject = new Url(); - $baseUrl = $request->getBaseUrl(); - $urlObject->setBaseUrl($baseUrl); + + if ($url === '#') { + $urlObject->setPath($url); + return $urlObject; + } $urlParts = parse_url($url); - if (isset($urlParts['path'])) { - if ($baseUrl !== '' && strpos($urlParts['path'], $baseUrl) === 0) { - $urlObject->setPath(substr($urlParts['path'], strlen($baseUrl))); - } else { - $urlObject->setPath($urlParts['path']); - } + if (isset($urlParts['scheme']) && ( + $urlParts['scheme'] !== $request->getScheme() + || (isset($urlParts['host']) && $urlParts['host'] !== $request->getServer('SERVER_NAME')) + || (isset($urlParts['port']) && $urlParts['port'] != $request->getServer('SERVER_PORT'))) + ) { + $baseUrl = $urlParts['scheme'] . '://' . $urlParts['host'] . (isset($urlParts['port']) + ? (':' . $urlParts['port']) + : ''); + $urlObject->setIsExternal(); + } else { + $baseUrl = ''; } + + if (isset($urlParts['path'])) { + $urlPath = $urlParts['path']; + if ($urlPath && $urlPath[0] === '/') { + $baseUrl = ''; + } elseif (! $baseUrl) { + $baseUrl = $request->getBaseUrl(); + } + + if ($baseUrl && !$urlObject->isExternal() && strpos($urlPath, $baseUrl) === 0) { + $urlObject->setPath(substr($urlPath, strlen($baseUrl))); + } else { + $urlObject->setPath($urlPath); + } + } elseif (! $baseUrl) { + $baseUrl = $request->getBaseUrl(); + } + // TODO: This has been used by former filter implementation, remove it: if (isset($urlParts['query'])) { $params = UrlParams::fromQueryString($urlParts['query'])->mergeValues($params); @@ -152,6 +182,7 @@ class Url $urlObject->setAnchor($urlParts['fragment']); } + $urlObject->setBaseUrl($baseUrl); $urlObject->setParams($params); return $urlObject; } @@ -179,19 +210,13 @@ class Url /** * Overwrite the baseUrl * - * If an empty Url is given '/' is used as the base - * * @param string $baseUrl The url path to use as the Url Base * * @return $this */ public function setBaseUrl($baseUrl) { - if (($baseUrl = rtrim($baseUrl, '/ ')) === '') { - $baseUrl = '/'; - } - - $this->baseUrl = $baseUrl; + $this->baseUrl = rtrim($baseUrl, '/ '); return $this; } @@ -231,17 +256,61 @@ class Url } /** - * Return the relative url with query parameters as a string + * Set whether this url points to an external resource + * + * @param bool $state + * + * @return $this + */ + public function setIsExternal($state = true) + { + $this->external = (bool) $state; + return $this; + } + + /** + * Return whether this url points to an external resource + * + * @return bool + */ + public function isExternal() + { + return $this->external; + } + + /** + * Return the relative url * * @return string */ public function getRelativeUrl($separator = '&') { - if ($this->params->isEmpty()) { - return $this->path . $this->anchor; - } else { - return $this->path . '?' . $this->params->toString($separator) . $this->anchor; + $path = $this->buildPathQueryAndFragment($separator); + if ($path && $path[0] === '/') { + return ''; } + + return $path; + } + + /** + * Return this url's path with its query parameters and fragment as string + * + * @return string + */ + protected function buildPathQueryAndFragment($querySeparator) + { + $anchor = $this->getAnchor(); + if ($anchor) { + $anchor = '#' . $anchor; + } + + $query = $this->getQueryString($querySeparator); + if ($query) { + $query = '?' . $query; + } + + return $this->getPath() . $query . $anchor; } public function setQueryString($queryString) @@ -250,9 +319,9 @@ class Url return $this; } - public function getQueryString() + public function getQueryString($separator = null) { - return (string) $this->params; + return $this->params->toString($separator); } /** @@ -262,7 +331,17 @@ class Url */ public function getAbsoluteUrl($separator = '&') { - return $this->baseUrl . ($this->baseUrl !== '/' ? '/' : '') . $this->getRelativeUrl($separator); + $path = $this->buildPathQueryAndFragment($separator); + if ($path && ($path === '#' || $path[0] === '/')) { + return $path; + } + + $baseUrl = $this->getBaseUrl(); + if (! $baseUrl) { + $baseUrl = '/'; + } + + return $baseUrl . ($baseUrl !== '/' && $path ? '/' : '') . $path; } /** @@ -380,10 +459,20 @@ class Url */ public function setAnchor($anchor) { - $this->anchor = '#' . $anchor; + $this->anchor = $anchor; return $this; } + /** + * Return the url anchor-part + * + * @return string The site's anchor string without the '#' + */ + public function getAnchor() + { + return $this->anchor; + } + /** * Remove provided key (if string) or keys (if array of string) from the query parameter array * diff --git a/library/Icinga/Web/Widget/Dashboard.php b/library/Icinga/Web/Widget/Dashboard.php index 758ec8267..c69fb4477 100644 --- a/library/Icinga/Web/Widget/Dashboard.php +++ b/library/Icinga/Web/Widget/Dashboard.php @@ -9,6 +9,8 @@ use Icinga\Exception\ConfigurationError; use Icinga\Exception\NotReadableError; use Icinga\Exception\ProgrammingError; use Icinga\User; +use Icinga\Web\Navigation\DashboardPane; +use Icinga\Web\Navigation\Navigation; use Icinga\Web\Widget\Dashboard\Pane; use Icinga\Web\Widget\Dashboard\Dashlet as DashboardDashlet; use Icinga\Web\Url; @@ -68,16 +70,21 @@ class Dashboard extends AbstractWidget */ public function load() { - $manager = Icinga::app()->getModuleManager(); - foreach ($manager->getLoadedModules() as $module) { - if ($this->getUser()->can($manager::MODULE_PERMISSION_NS . $module->getName())) { - $this->mergePanes($module->getPaneItems()); + $navigation = new Navigation(); + $navigation->load('dashboard-pane'); + + $panes = array(); + foreach ($navigation as $dashboardPane) { + /** @var DashboardPane $dashboardPane */ + $pane = new Pane($dashboardPane->getLabel()); + foreach ($dashboardPane->getDashlets() as $title => $url) { + $pane->addDashlet($title, $url); } + $panes[] = $pane; } - $this->loadUserDashboards(); - + $this->mergePanes($panes); return $this; } diff --git a/modules/monitoring/application/forms/Navigation/ActionForm.php b/modules/monitoring/application/forms/Navigation/ActionForm.php new file mode 100644 index 000000000..e712b254d --- /dev/null +++ b/modules/monitoring/application/forms/Navigation/ActionForm.php @@ -0,0 +1,75 @@ +addElement( + 'text', + 'filter', + array( + 'allowEmpty' => true, + 'label' => $this->translate('Filter'), + 'description' => $this->translate( + 'Display this action only for objects matching this filter. Leave it blank' + . ' if you want this action being displayed regardless of the object' + ) + ) + ); + } + + /** + * {@inheritdoc} + */ + public function isValid($formData) + { + if (! parent::isValid($formData)) { + return false; + } + + if (($filterString = $this->getValue('filter')) !== null) { + $filter = Filter::matchAll(); + $filter->setAllowedFilterColumns(array( + 'host_name', + 'hostgroup_name', + 'instance_name', + 'service_description', + 'servicegroup_name', + function ($c) { + return preg_match('/^_(?:host|service)_/', $c); + } + )); + + try { + $filter->addFilter(Filter::fromQueryString($filterString)); + } catch (QueryException $_) { + $this->getElement('filter')->addError(sprintf( + $this->translate('Invalid filter provided. You can only use the following columns: %s'), + implode(', ', array( + 'instance_name', + 'host_name', + 'hostgroup_name', + 'service_description', + 'servicegroup_name', + '_(host|service)_' + )) + )); + return false; + } + } + + return true; + } +} diff --git a/modules/monitoring/application/forms/Navigation/HostActionForm.php b/modules/monitoring/application/forms/Navigation/HostActionForm.php new file mode 100644 index 000000000..2e8bf32cc --- /dev/null +++ b/modules/monitoring/application/forms/Navigation/HostActionForm.php @@ -0,0 +1,8 @@ +

    - + icon('plus'); ?> translate('Create New Monitoring Backend'); ?>

    @@ -20,7 +20,7 @@ qlink( $backendName, - '/monitoring/config/editbackend', + 'monitoring/config/editbackend', array('backend-name' => $backendName), array( 'icon' => 'edit', @@ -35,7 +35,7 @@ qlink( '', - '/monitoring/config/removebackend', + 'monitoring/config/removebackend', array('backend-name' => $backendName), array( 'icon' => 'trash', @@ -49,7 +49,7 @@

    translate('Command Transports') ?>

    - + icon('plus'); ?> translate('Create New Transport'); ?>

    @@ -64,7 +64,7 @@ qlink( $transportName, - '/monitoring/config/edittransport', + 'monitoring/config/edittransport', array('transport' => $transportName), array( 'icon' => 'edit', @@ -79,7 +79,7 @@ qlink( '', - '/monitoring/config/removetransport', + 'monitoring/config/removetransport', array('transport' => $transportName), array( 'icon' => 'trash', diff --git a/modules/monitoring/application/views/scripts/list/comments.phtml b/modules/monitoring/application/views/scripts/list/comments.phtml index 6e3f4dcc5..5cd7d298b 100644 --- a/modules/monitoring/application/views/scripts/list/comments.phtml +++ b/modules/monitoring/application/views/scripts/list/comments.phtml @@ -14,7 +14,7 @@
    " data-icinga-multiselect-data="comment_id"> diff --git a/modules/monitoring/application/views/scripts/list/contactgroups.phtml b/modules/monitoring/application/views/scripts/list/contactgroups.phtml index 3030d4224..099db3979 100644 --- a/modules/monitoring/application/views/scripts/list/contactgroups.phtml +++ b/modules/monitoring/application/views/scripts/list/contactgroups.phtml @@ -27,7 +27,7 @@ if (count($groupData) === 0) {
    - img('/static/gravatar', array('email' => $c->contact_email)); ?> + img('static/gravatar', array('email' => $c->contact_email)); ?> qlink( $c->contact_alias, 'monitoring/show/contact', diff --git a/modules/monitoring/application/views/scripts/list/contacts.phtml b/modules/monitoring/application/views/scripts/list/contacts.phtml index 3e10197fb..4cb32101d 100644 --- a/modules/monitoring/application/views/scripts/list/contacts.phtml +++ b/modules/monitoring/application/views/scripts/list/contacts.phtml @@ -10,7 +10,7 @@
    peekAhead($this->compact) as $contact): ?>
    - img('/static/gravatar', array('email' => $contact->contact_email)); ?> + img('static/gravatar', array('email' => $contact->contact_email)); ?> qlink( $contact->contact_name, 'monitoring/show/contact', diff --git a/modules/monitoring/application/views/scripts/list/downtimes.phtml b/modules/monitoring/application/views/scripts/list/downtimes.phtml index 7aae0454d..709b7ec33 100644 --- a/modules/monitoring/application/views/scripts/list/downtimes.phtml +++ b/modules/monitoring/application/views/scripts/list/downtimes.phtml @@ -20,7 +20,7 @@ if (! $this->compact): ?>
    " data-icinga-multiselect-data="downtime_id"> diff --git a/modules/monitoring/application/views/scripts/show/components/actions.phtml b/modules/monitoring/application/views/scripts/show/components/actions.phtml index 329a06fb9..2e55db68c 100644 --- a/modules/monitoring/application/views/scripts/show/components/actions.phtml +++ b/modules/monitoring/application/views/scripts/show/components/actions.phtml @@ -1,25 +1,38 @@ load($object->getType() . '-action'); +foreach ($navigation as $item) { + $item->setObject($object); +} + // add warning to links that open in new tabs to improve accessibility, as recommended by WCAG20 G201 $newTabInfo = sprintf(' %s ', $this->translate('opens in new window')); -$links = $object->getActionUrls(); -foreach ($links as $i => $link) { - $links[$i] = sprintf('%s ' . $newTabInfo . '', $link, 'Action'); +foreach ($object->getActionUrls() as $i => $link) { + $navigation->addItem( + 'Action ' . ($i + 1) . $newTabInfo, + array( + 'url' => $link, + 'target' => '_blank' + ) + ); } if (isset($this->actions)) { foreach ($this->actions as $id => $action) { - $links[] = sprintf('%s', $action, $id); + $navigation->addItem($id, array('url' => $action)); } } -if (empty($links)) { +if ($navigation->isEmpty() || !$navigation->hasRenderableItems()) { return; } ?> - - - + + getRenderer()->setElementTag('td')->setCssClass('actions'); ?> + \ No newline at end of file diff --git a/modules/monitoring/application/views/scripts/show/components/notes.phtml b/modules/monitoring/application/views/scripts/show/components/notes.phtml index 4a5041e50..1b3dafc52 100644 --- a/modules/monitoring/application/views/scripts/show/components/notes.phtml +++ b/modules/monitoring/application/views/scripts/show/components/notes.phtml @@ -1,26 +1,42 @@ getNotes()); -$links = $object->getNotesUrls(); -if (! empty($links) || ! empty($notes)): ?> +use Icinga\Web\Navigation\Navigation; + +$navigation = new Navigation(); +$navigation->load($object->getType() . '-note'); +foreach ($navigation as $item) { + $item->setObject($object); +} + +$notes = trim($object->getNotes()); +if ($notes) { + $navigation->addItem($notes); +} + +$links = $object->getNotesUrls(); +if (! empty($links)) { + // add warning to links that open in new tabs to improve accessibility, as recommended by WCAG20 G201 + $newTabInfo = sprintf( + ' %s ', + $this->translate('opens in new window') + ); + + foreach ($links as $link) { + $navigation->addItem( + $this->escape($link) . $newTabInfo, + array( + 'url' => $link, + 'target' => '_blank' + ) + ); + } +} + +if ($navigation->isEmpty() || !$navigation->hasRenderableItems()) { + return; +} +?> - - - - + + getRenderer()->setElementTag('td')->setCssClass('notes'); ?> + \ No newline at end of file diff --git a/modules/monitoring/application/views/scripts/timeline/index.phtml b/modules/monitoring/application/views/scripts/timeline/index.phtml index 209eb0f1b..56312828d 100644 --- a/modules/monitoring/application/views/scripts/timeline/index.phtml +++ b/modules/monitoring/application/views/scripts/timeline/index.phtml @@ -65,7 +65,7 @@ if (! $beingExtended && !$this->compact): ?>
    qlink( $timeInfo[0]->end->format($intervalFormat), - '/monitoring/list/eventhistory', + 'monitoring/list/eventhistory', array( 'timestamp<' => $timeInfo[0]->start->getTimestamp(), 'timestamp>' => $timeInfo[0]->end->getTimestamp() diff --git a/modules/monitoring/configuration.php b/modules/monitoring/configuration.php index b9b8118a8..c1c6d5b69 100644 --- a/modules/monitoring/configuration.php +++ b/modules/monitoring/configuration.php @@ -85,20 +85,29 @@ $this->provideSearchUrl($this->translate('Services'), 'monitoring/list/services? $this->provideSearchUrl($this->translate('Hostgroups'), 'monitoring/list/hostgroups?limit=10', 97); $this->provideSearchUrl($this->translate('Servicegroups'), 'monitoring/list/servicegroups?limit=10', 96); +/* + * Available navigation items + */ +$this->provideNavigationItem('host-action', $this->translate('Host Action')); +$this->provideNavigationItem('service-action', $this->translate('Service Action')); +// Notes are disabled as we're not sure whether to really make a difference between actions and notes +//$this->provideNavigationItem('host-note', $this->translate('Host Note')); +//$this->provideNavigationItem('service-note', $this->translate('Service Note')); + /* * Problems Section */ -$section = $this->menuSection($this->translate('Problems'), array( +$section = $this->menuSection(N_('Problems'), array( 'renderer' => array( - 'SummaryMenuItemRenderer', + 'SummaryNavigationItemRenderer', 'state' => 'critical' ), 'icon' => 'block', 'priority' => 20 )); -$section->add($this->translate('Unhandled Hosts'), array( +$section->add(N_('Unhandled Hosts'), array( 'renderer' => array( - 'Icinga\Module\Monitoring\Web\Menu\MonitoringBadgeMenuItemRenderer', + 'MonitoringBadgeNavigationItemRenderer', 'columns' => array( 'hosts_down_unhandled' => $this->translate('%d unhandled hosts down') ), @@ -108,9 +117,9 @@ $section->add($this->translate('Unhandled Hosts'), array( 'url' => 'monitoring/list/hosts?host_problem=1&host_handled=0', 'priority' => 30 )); -$section->add($this->translate('Unhandled Services'), array( +$section->add(N_('Unhandled Services'), array( 'renderer' => array( - 'Icinga\Module\Monitoring\Web\Menu\MonitoringBadgeMenuItemRenderer', + 'MonitoringBadgeNavigationItemRenderer', 'columns' => array( 'services_critical_unhandled' => $this->translate('%d unhandled services critical') ), @@ -120,19 +129,19 @@ $section->add($this->translate('Unhandled Services'), array( 'url' => 'monitoring/list/services?service_problem=1&service_handled=0&sort=service_severity', 'priority' => 40 )); -$section->add($this->translate('Host Problems'), array( +$section->add(N_('Host Problems'), array( 'url' => 'monitoring/list/hosts?host_problem=1&sort=host_severity', 'priority' => 50 )); -$section->add($this->translate('Service Problems'), array( +$section->add(N_('Service Problems'), array( 'url' => 'monitoring/list/services?service_problem=1&sort=service_severity&dir=desc', 'priority' => 60 )); -$section->add($this->translate('Service Grid'), array( +$section->add(N_('Service Grid'), array( 'url' => 'monitoring/list/servicegrid?problems', 'priority' => 70 )); -$section->add($this->translate('Current Downtimes'), array( +$section->add(N_('Current Downtimes'), array( 'url' => 'monitoring/list/downtimes?downtime_is_in_effect=1', 'priority' => 80 )); @@ -140,43 +149,43 @@ $section->add($this->translate('Current Downtimes'), array( /* * Overview Section */ -$section = $this->menuSection($this->translate('Overview'), array( +$section = $this->menuSection(N_('Overview'), array( 'icon' => 'sitemap', 'priority' => 30 )); -$section->add($this->translate('Tactical Overview'), array( +$section->add(N_('Tactical Overview'), array( 'url' => 'monitoring/tactical', 'priority' => 40 )); -$section->add($this->translate('Hosts'), array( +$section->add(N_('Hosts'), array( 'url' => 'monitoring/list/hosts', 'priority' => 50 )); -$section->add($this->translate('Services'), array( +$section->add(N_('Services'), array( 'url' => 'monitoring/list/services', 'priority' => 50 )); -$section->add($this->translate('Servicegroups'), array( +$section->add(N_('Servicegroups'), array( 'url' => 'monitoring/list/servicegroups', 'priority' => 60 )); -$section->add($this->translate('Hostgroups'), array( +$section->add(N_('Hostgroups'), array( 'url' => 'monitoring/list/hostgroups', 'priority' => 60 )); -$section->add($this->translate('Contacts'), array( +$section->add(N_('Contacts'), array( 'url' => 'monitoring/list/contacts', 'priority' => 70 )); -$section->add($this->translate('Contactgroups'), array( +$section->add(N_('Contactgroups'), array( 'url' => 'monitoring/list/contactgroups', 'priority' => 70 )); -$section->add($this->translate('Comments'), array( +$section->add(N_('Comments'), array( 'url' => 'monitoring/list/comments?comment_type=(comment|ack)', 'priority' => 80 )); -$section->add($this->translate('Downtimes'), array( +$section->add(N_('Downtimes'), array( 'url' => 'monitoring/list/downtimes', 'priority' => 80 )); @@ -184,22 +193,23 @@ $section->add($this->translate('Downtimes'), array( /* * History Section */ -$section = $this->menuSection($this->translate('History'), array( - 'icon' => 'rewind' +$section = $this->menuSection(N_('History'), array( + 'icon' => 'rewind', + 'priority' => 90 )); -$section->add($this->translate('Event Grid'), array( +$section->add(N_('Event Grid'), array( 'priority' => 10, 'url' => 'monitoring/list/eventgrid' )); -$section->add($this->translate('Event Overview'), array( +$section->add(N_('Event Overview'), array( 'priority' => 20, 'url' => 'monitoring/list/eventhistory?timestamp>=-7%20days' )); -$section->add($this->translate('Notifications'), array( +$section->add(N_('Notifications'), array( 'priority' => 30, 'url' => 'monitoring/list/notifications', )); -$section->add($this->translate('Timeline'), array( +$section->add(N_('Timeline'), array( 'priority' => 40, 'url' => 'monitoring/timeline' )); @@ -207,144 +217,144 @@ $section->add($this->translate('Timeline'), array( /* * Reporting Section */ -$section = $this->menuSection($this->translate('Reporting'), array( +$section = $this->menuSection(N_('Reporting'), array( 'icon' => 'barchart', 'priority' => 100 )); -$section->add($this->translate('Alert Summary'), array( +$section->add(N_('Alert Summary'), array( 'url' => 'monitoring/alertsummary/index' )); /* * System Section */ -$section = $this->menuSection($this->translate('System')); -$section->add($this->translate('Monitoring Health'), array( +$section = $this->menuSection(N_('System')); +$section->add(N_('Monitoring Health'), array( 'url' => 'monitoring/health/info', 'priority' => 720, - 'renderer' => 'Icinga\Module\Monitoring\Web\Menu\BackendAvailabilityMenuItemRenderer' + 'renderer' => 'BackendAvailabilityNavigationItemRenderer' )); /* * Current Incidents */ -$dashboard = $this->dashboard($this->translate('Current Incidents')); +$dashboard = $this->dashboard(N_('Current Incidents'), array('priority' => 50)); $dashboard->add( - $this->translate('Service Problems'), + N_('Service Problems'), 'monitoring/list/services?service_problem=1&limit=10&sort=service_severity' ); $dashboard->add( - $this->translate('Recently Recovered Services'), + N_('Recently Recovered Services'), 'monitoring/list/services?service_state=0&limit=10&sort=service_last_state_change&dir=desc' ); $dashboard->add( - $this->translate('Host Problems'), + N_('Host Problems'), 'monitoring/list/hosts?host_problem=1&sort=host_severity' ); /* * Overview */ -$dashboard = $this->dashboard($this->translate('Overview')); +$dashboard = $this->dashboard(N_('Overview'), array('priority' => 60)); $dashboard->add( - $this->translate('Service Grid'), + N_('Service Grid'), 'monitoring/list/servicegrid?limit=15,18' ); $dashboard->add( - $this->translate('Service Groups'), - '/monitoring/list/servicegroups' + N_('Service Groups'), + 'monitoring/list/servicegroups' ); $dashboard->add( - $this->translate('Host Groups'), - '/monitoring/list/hostgroups' + N_('Host Groups'), + 'monitoring/list/hostgroups' ); /* * Most Overdue */ -$dashboard = $this->dashboard($this->translate('Overdue')); +$dashboard = $this->dashboard(N_('Overdue'), array('priority' => 70)); $dashboard->add( - $this->translate('Late Host Check Results'), + N_('Late Host Check Results'), 'monitoring/list/hosts?host_next_updateadd( - $this->translate('Late Service Check Results'), + N_('Late Service Check Results'), 'monitoring/list/services?service_next_updateadd( - $this->translate('Acknowledgements Active For At Least Three Days'), + N_('Acknowledgements Active For At Least Three Days'), 'monitoring/list/comments?comment_type=Ack&comment_timestamp<-3 days&sort=comment_timestamp&dir=asc' ); $dashboard->add( - $this->translate('Downtimes Active For More Than Three Days'), + N_('Downtimes Active For More Than Three Days'), 'monitoring/list/downtimes?downtime_is_in_effect=1&downtime_scheduled_start<-3%20days&sort=downtime_start&dir=asc' ); /* * Muted Objects */ -$dashboard = $this->dashboard($this->translate('Muted')); +$dashboard = $this->dashboard(N_('Muted'), array('priority' => 80)); $dashboard->add( - $this->translate('Disabled Service Notifications'), + N_('Disabled Service Notifications'), 'monitoring/list/services?service_notifications_enabled=0&limit=10' ); $dashboard->add( - $this->translate('Disabled Host Notifications'), + N_('Disabled Host Notifications'), 'monitoring/list/hosts?host_notifications_enabled=0&limit=10' ); $dashboard->add( - $this->translate('Disabled Service Checks'), + N_('Disabled Service Checks'), 'monitoring/list/services?service_active_checks_enabled=0&limit=10' ); $dashboard->add( - $this->translate('Disabled Host Checks'), + N_('Disabled Host Checks'), 'monitoring/list/hosts?host_active_checks_enabled=0&limit=10' ); $dashboard->add( - $this->translate('Acknowledged Problem Services'), + N_('Acknowledged Problem Services'), 'monitoring/list/services?service_acknowledgement_type=2&service_problem=1&sort=service_state&limit=10' ); $dashboard->add( - $this->translate('Acknowledged Problem Hosts'), + N_('Acknowledged Problem Hosts'), 'monitoring/list/hosts?host_acknowledgement_type=2&host_problem=1&sort=host_severity&limit=10' ); /* * Activity Stream */ -$dashboard = $this->dashboard($this->translate('Activity Stream')); +$dashboard = $this->dashboard(N_('Activity Stream'), array('priority' => 90)); $dashboard->add( - $this->translate('Recent Events'), + N_('Recent Events'), 'monitoring/list/eventhistory?timestamp>=-3%20days&sort=timestamp&dir=desc&limit=8' ); $dashboard->add( - $this->translate('Recent Hard State Changes'), + N_('Recent Hard State Changes'), 'monitoring/list/eventhistory?timestamp>=-3%20days&type=hard_state&sort=timestamp&dir=desc&limit=8' ); $dashboard->add( - $this->translate('Recent Notifications'), + N_('Recent Notifications'), 'monitoring/list/eventhistory?timestamp>=-3%20days&type=notify&sort=timestamp&dir=desc&limit=8' ); $dashboard->add( - $this->translate('Downtimes Recently Started'), + N_('Downtimes Recently Started'), 'monitoring/list/eventhistory?timestamp>=-3%20days&type=dt_start&sort=timestamp&dir=desc&limit=8' ); $dashboard->add( - $this->translate('Downtimes Recently Ended'), + N_('Downtimes Recently Ended'), 'monitoring/list/eventhistory?timestamp>=-3%20days&type=dt_end&sort=timestamp&dir=desc&limit=8' ); /* * Stats */ -$dashboard = $this->dashboard($this->translate('Stats')); +$dashboard = $this->dashboard(N_('Stats'), array('priority' => 99)); $dashboard->add( - $this->translate('Check Stats'), + N_('Check Stats'), 'monitoring/health/stats' ); $dashboard->add( - $this->translate('Process Information'), + N_('Process Information'), 'monitoring/health/info' ); diff --git a/modules/monitoring/library/Monitoring/Object/MonitoredObject.php b/modules/monitoring/library/Monitoring/Object/MonitoredObject.php index 0896cd266..98ada34de 100644 --- a/modules/monitoring/library/Monitoring/Object/MonitoredObject.php +++ b/modules/monitoring/library/Monitoring/Object/MonitoredObject.php @@ -5,9 +5,11 @@ namespace Icinga\Module\Monitoring\Object; use InvalidArgumentException; use Icinga\Application\Config; +use Icinga\Application\Logger; use Icinga\Data\Filter\Filter; use Icinga\Data\Filterable; use Icinga\Exception\InvalidPropertyException; +use Icinga\Exception\ProgrammingError; use Icinga\Module\Monitoring\Backend\MonitoringBackend; use Icinga\Web\UrlParams; @@ -208,6 +210,59 @@ abstract class MonitoredObject implements Filterable // Left out on purpose. Interface is deprecated. } + /** + * Return whether this object matches the given filter + * + * @param Filter $filter + * + * @return bool + * + * @throws ProgrammingError In case the object cannot be found + */ + public function matches(Filter $filter) + { + if ($this->properties === null && $this->fetch() === false) { + throw new ProgrammingError( + 'Unable to apply filter. Object %s of type %s not found.', + $this->getName(), + $this->getType() + ); + } + + $queryString = $filter->toQueryString(); + $row = clone $this->properties; + + if (strpos($queryString, '_host_') !== false || strpos($queryString, '_service_') !== false) { + if ($this->customvars === null) { + $this->fetchCustomvars(); + } + + foreach ($this->customvars as $name => $value) { + if (! is_object($value)) { + $row->{'_' . $this->getType() . '_' . strtolower(str_replace(' ', '_', $name))} = $value; + } + } + } + + if (strpos($queryString, 'hostgroup_name') !== false) { + if ($this->hostgroups === null) { + $this->fetchHostgroups(); + } + + $row->hostgroup_name = array_keys($this->hostgroups); + } + + if (strpos($queryString, 'servicegroup_name') !== false) { + if ($this->servicegroups === null) { + $this->fetchServicegroups(); + } + + $row->servicegroup_name = array_keys($this->servicegroups); + } + + return $filter->matches($row); + } + /** * Require the object's type to be one of the given types * @@ -529,12 +584,15 @@ abstract class MonitoredObject implements Filterable */ public function fetchServicegroups() { - $this->servicegroups = $this->backend->select() + $query = $this->backend->select() ->from('servicegroup', array('servicegroup_name', 'servicegroup_alias')) - ->where('host_name', $this->host_name) - ->where('service_description', $this->service_description) - ->applyFilter($this->getFilter()) - ->fetchPairs(); + ->where('host_name', $this->host_name); + + if ($this->type === self::TYPE_SERVICE) { + $query->where('service_description', $this->service_description); + } + + $this->servicegroups = $query->applyFilter($this->getFilter())->fetchPairs(); return $this; } diff --git a/modules/monitoring/library/Monitoring/Web/Navigation/Action.php b/modules/monitoring/library/Monitoring/Web/Navigation/Action.php new file mode 100644 index 000000000..505229abb --- /dev/null +++ b/modules/monitoring/library/Monitoring/Web/Navigation/Action.php @@ -0,0 +1,109 @@ +object = $object; + return $this; + } + + /** + * Return this action's object + * + * @return MonitoredObject + */ + public function getObject() + { + return $this->object; + } + + /** + * Set the filter to use when being asked whether to render this action + * + * @param string $filter + * + * @return $this + */ + public function setFilter($filter) + { + $this->filter = $filter; + return $this; + } + + /** + * Return the filter to use when being asked whether to render this action + * + * @return string + */ + public function getFilter() + { + return $this->filter; + } + + /** + * {@inheritdoc} + */ + public function getUrl() + { + $url = parent::getUrl(); + if (! $this->resolved && $url !== null) { + $this->setUrl(Macro::resolveMacros($url->getAbsoluteUrl(), $this->getObject())); + $this->resolved = true; + } + + return $url; + } + + /** + * {@inheritdoc} + */ + public function getRender() + { + if ($this->render === null) { + $filter = $this->getFilter(); + $this->render = $filter ? $this->getObject()->matches(Filter::fromQueryString($filter)) : true; + } + + return $this->render; + } +} diff --git a/modules/monitoring/library/Monitoring/Web/Navigation/HostAction.php b/modules/monitoring/library/Monitoring/Web/Navigation/HostAction.php new file mode 100644 index 000000000..9d391dfc7 --- /dev/null +++ b/modules/monitoring/library/Monitoring/Web/Navigation/HostAction.php @@ -0,0 +1,11 @@ +isCurrentlyRunning()) { - return 1; + try { + if ($this->isCurrentlyRunning()) { + return 0; + } + } catch (Exception $_) { + // pass } - return 0; + + return 1; } /** diff --git a/modules/monitoring/library/Monitoring/Web/Menu/MonitoringBadgeMenuItemRenderer.php b/modules/monitoring/library/Monitoring/Web/Navigation/Renderer/MonitoringBadgeNavigationItemRenderer.php similarity index 58% rename from modules/monitoring/library/Monitoring/Web/Menu/MonitoringBadgeMenuItemRenderer.php rename to modules/monitoring/library/Monitoring/Web/Navigation/Renderer/MonitoringBadgeNavigationItemRenderer.php index 90d02fe52..14c44956e 100644 --- a/modules/monitoring/library/Monitoring/Web/Menu/MonitoringBadgeMenuItemRenderer.php +++ b/modules/monitoring/library/Monitoring/Web/Navigation/Renderer/MonitoringBadgeNavigationItemRenderer.php @@ -1,23 +1,26 @@ dataView = $dataView; + return $this; + } - $this->columns = $configuration->get('columns'); - $this->state = $configuration->get('state'); - $this->dataView = $configuration->get('dataView'); + /** + * Return the dataview referred to by the navigation item + * + * @return string + */ + public function getDataView() + { + return $this->dataView; + } + /** + * Set the columns and titles displayed in the badge + * + * @param array $columns + * + * @return $this + */ + public function setColumns(array $columns) + { + $this->columns = $columns; + return $this; + } + + /** + * Return the columns and titles displayed in the badge + * + * @return array + */ + public function getColumns() + { + return $this->columns; + } + + /** + * {@inheritdoc} + */ + public function init() + { // clear the outdated summary cache, since new columns are being added. Optimally all menu item are constructed // before any rendering is going on to avoid trashing too man old requests if (isset(self::$summaries[$this->dataView])) { @@ -89,11 +112,11 @@ class MonitoringBadgeMenuItemRenderer extends BadgeMenuItemRenderer if (! isset(self::$dataViews[$this->dataView])) { self::$dataViews[$this->dataView] = array(); } + foreach ($this->columns as $column => $title) { if (! array_search($column, self::$dataViews[$this->dataView])) { self::$dataViews[$this->dataView][] = $column; } - $this->titles[$column] = $title; } } @@ -116,12 +139,11 @@ class MonitoringBadgeMenuItemRenderer extends BadgeMenuItemRenderer } /** - * Fetch the response from the database or access cache + * Fetch the dataview from the database or access cache * - * @param $view + * @param string $view * - * @return null - * @throws \Icinga\Exception\ConfigurationError + * @return object */ protected static function summary($view) { @@ -133,51 +155,29 @@ class MonitoringBadgeMenuItemRenderer extends BadgeMenuItemRenderer static::applyRestriction('monitoring/filter/objects', $summary); self::$summaries[$view] = $summary->fetchRow(); } - return isset(self::$summaries[$view]) ? self::$summaries[$view] : null; + + return self::$summaries[$view]; } /** - * Defines the color of the badge - * - * @return string - */ - public function getState() - { - return $this->state; - } - - /** - * The amount of items to display in the badge - * - * @return int + * {@inheritdoc} */ public function getCount() { - $sum = self::summary($this->dataView); - $count = 0; + try { + $summary = self::summary($this->getDataView()); + } catch (Exception $_) { + return 0; + } - foreach ($this->columns as $col => $title) { - if (isset($sum->$col)) { - $count += $sum->$col; + $count = 0; + foreach ($this->getColumns() as $column => $title) { + if (isset($summary->$column) && $summary->$column > 0) { + $this->titles[] = sprintf($title, $summary->$column); + $count += $summary->$column; } } + return $count; } - - /** - * The tooltip title - * - * @return string - */ - public function getTitle() - { - $titles = array(); - $sum = $this->summary($this->dataView); - foreach ($this->columns as $column => $value) { - if (isset($sum->$column) && $sum->$column > 0) { - $titles[] = sprintf($this->titles[$column], $sum->$column); - } - } - return implode(', ', $titles); - } } diff --git a/modules/monitoring/library/Monitoring/Web/Navigation/ServiceAction.php b/modules/monitoring/library/Monitoring/Web/Navigation/ServiceAction.php new file mode 100644 index 000000000..28fd98227 --- /dev/null +++ b/modules/monitoring/library/Monitoring/Web/Navigation/ServiceAction.php @@ -0,0 +1,11 @@ +assertEquals( - '/', + '', $url->getBaseUrl(), 'Url::fromPath does not recognize the correct base url' ); $this->assertEquals( - 'my/test/url.html', - $url->getPath(), + '/my/test/url.html', + $url->getAbsoluteUrl(), 'Url::fromPath does not recognize the correct url path' ); } diff --git a/test/php/library/Icinga/Web/Widget/DashboardTest.php b/test/php/library/Icinga/Web/Widget/DashboardTest.php index 181dec0a9..9387ed483 100644 --- a/test/php/library/Icinga/Web/Widget/DashboardTest.php +++ b/test/php/library/Icinga/Web/Widget/DashboardTest.php @@ -9,7 +9,6 @@ require_once realpath(dirname(__FILE__) . '/../../../../bootstrap.php'); use Mockery; use Icinga\Test\BaseTestCase; -use Icinga\User; use Icinga\Web\Widget\Dashboard; use Icinga\Web\Widget\Dashboard\Pane; use Icinga\Web\Widget\Dashboard\Dashlet; @@ -46,23 +45,6 @@ class DashboardTest extends BaseTestCase Mockery::close(); // Necessary because some tests run in a separate process } - public function setUp() - { - $moduleMock = Mockery::mock('Icinga\Application\Modules\Module'); - $moduleMock->shouldReceive('getPaneItems')->andReturn(array( - 'test-pane' => new Pane('Test Pane') - )); - $moduleMock->shouldReceive('getName')->andReturn('test'); - - $moduleManagerMock = Mockery::mock('Icinga\Application\Modules\Manager'); - $moduleManagerMock->shouldReceive('getLoadedModules')->andReturn(array( - 'test-module' => $moduleMock - )); - - $bootstrapMock = $this->setupIcingaMock(); - $bootstrapMock->shouldReceive('getModuleManager')->andReturn($moduleManagerMock); - } - public function testWhetherCreatePaneCreatesAPane() { $dashboard = new Dashboard(); @@ -126,24 +108,6 @@ class DashboardTest extends BaseTestCase ); } - /** - * @depends testWhetherCreatePaneCreatesAPane - */ - public function testLoadPaneItemsProvidedByEnabledModules() - { - $user = new User('test'); - $user->setPermissions(array('*' => '*')); - $dashboard = new Dashboard(); - $dashboard->setUser($user); - $dashboard->load(); - - $this->assertCount( - 1, - $dashboard->getPanes(), - 'Dashboard::load() could not load panes from enabled modules' - ); - } - /** * @expectedException \Icinga\Exception\ProgrammingError * @depends testWhetherCreatePaneCreatesAPane
    translate('Actions') ?>", $links) ?>
    translate('Actions'); ?>
    translate('Notes') ?> - '; - } - // add warning to links that open in new tabs to improve accessibility, as recommended by WCAG20 G201 - $newTabInfo = sprintf( - ' %s ', - $this->translate('opens in new window') - ); - $linkText = '%s ' . $newTabInfo . ''; - foreach ($links as $i => $link) { - $links[$i] = sprintf($linkText, $this->escape($link), $this->escape($link)); - } - echo implode('
    ', $links); - ?> -
    translate('Notes'); ?>