diff --git a/.vagrant-puppet/files/etc/icinga2/conf.d/test-config.conf b/.vagrant-puppet/files/etc/icinga2/conf.d/test-config.conf index 412307328..f25df325b 100644 --- a/.vagrant-puppet/files/etc/icinga2/conf.d/test-config.conf +++ b/.vagrant-puppet/files/etc/icinga2/conf.d/test-config.conf @@ -1,6 +1,6 @@ object CheckCommand "dummy-host" { import "plugin-check-command" - command = [ PluginDir + "/libexec/test_hostcheck.pl" ] + command = [ PluginDir + "/test_hostcheck.pl" ] arguments = { "--type" = "$check_type$" "--failchance" = "$check_failchance$" @@ -18,7 +18,7 @@ object CheckCommand "dummy-host" { object CheckCommand "dummy-service" { import "plugin-check-command" - command = [ PluginDir + "/libexec/test_servicecheck.pl" ] + command = [ PluginDir + "/test_servicecheck.pl" ] arguments = { "--total-critical-on-host" = "$check_critical_on_host$" "--total-warning-on-host" = "$check_warning_on_host$" diff --git a/.vagrant-puppet/files/etc/icingaweb/menu.ini b/.vagrant-puppet/files/etc/icingaweb/menu.ini index f01ebaecc..d6f342655 100644 --- a/.vagrant-puppet/files/etc/icingaweb/menu.ini +++ b/.vagrant-puppet/files/etc/icingaweb/menu.ini @@ -18,10 +18,15 @@ title = "Configuration" url = "config" priority = 300 +[System.Modules] +title = "Modules" +url = "config/modules" +priority = 400 + [System.ApplicationLog] title = "Application log" url = "list/applicationlog" -priority = 400 +priority = 500 [Logout] url = "authentication/logout" diff --git a/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini b/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini new file mode 100644 index 000000000..86889b239 --- /dev/null +++ b/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini @@ -0,0 +1,5 @@ +[Documentation] +title = "Documentation" +icon = "img/icons/comment.png" +url = "doc" +priority = 80 diff --git a/.vagrant-puppet/manifests/default.pp b/.vagrant-puppet/manifests/default.pp index 3f77ab870..86065d8fc 100644 --- a/.vagrant-puppet/manifests/default.pp +++ b/.vagrant-puppet/manifests/default.pp @@ -3,7 +3,7 @@ include mysql include pgsql include openldap -Exec { path => '/bin:/usr/bin:/sbin' } +Exec { path => '/bin:/usr/bin:/sbin:/usr/sbin' } $icingaVersion = '1.11.5' $icinga2Version = '2.0.1' @@ -76,10 +76,10 @@ cmmi { 'icinga-mysql': --with-htmurl=/icinga-mysql --with-httpd-conf-file=/etc/httpd/conf.d/icinga-mysql.conf \ --with-cgiurl=/icinga-mysql/cgi-bin \ --with-http-auth-file=/usr/share/icinga/htpasswd.users \ - --with-plugin-dir=/usr/lib64/nagios/plugins/libexec', + --with-plugin-dir=/usr/lib64/nagios/plugins', creates => '/usr/local/icinga-mysql', make => 'make all && make fullinstall install-config', - require => [ User['icinga'], Cmmi['icinga-plugins'], Package['apache'] ], + require => [ User['icinga'], Class['monitoring-plugins'], Package['apache'] ], notify => Service['apache'] } @@ -102,10 +102,10 @@ cmmi { 'icinga-pgsql': --with-htmurl=/icinga-pgsql --with-httpd-conf-file=/etc/httpd/conf.d/icinga-pgsql.conf \ --with-cgiurl=/icinga-pgsql/cgi-bin \ --with-http-auth-file=/usr/share/icinga/htpasswd.users \ - --with-plugin-dir=/usr/lib64/nagios/plugins/libexec', + --with-plugin-dir=/usr/lib64/nagios/plugins', creates => '/usr/local/icinga-pgsql', make => 'make all && make fullinstall install-config', - require => [ User['icinga'], Cmmi['icinga-plugins'], Package['apache'] ], + require => [ User['icinga'], Class['monitoring-plugins'], Package['apache'] ], notify => Service['apache'] } @@ -210,16 +210,7 @@ exec { 'icinga-htpasswd': require => Class['apache'] } -cmmi { 'icinga-plugins': - url => "https://www.monitoring-plugins.org/download/monitoring-plugins-${pluginVersion}.tar.gz", - output => "monitoring-plugins-${pluginVersion}.tar.gz", - flags => '--prefix=/usr/lib64/nagios/plugins \ - --with-nagios-user=icinga --with-nagios-group=icinga \ - --with-cgiurl=/icinga-mysql/cgi-bin', - creates => '/usr/lib64/nagios/plugins/libexec', - make => 'make && make install', - require => User['icinga'] -} +include monitoring-plugins cmmi { 'mk-livestatus': url => "http://mathias-kettner.de/download/mk-livestatus-${livestatusVersion}.tar.gz", @@ -425,7 +416,7 @@ package { 'icinga2-ido-mysql': exec { 'populate-icinga2-mysql-db': unless => 'mysql -uicinga2 -picinga2 icinga2 -e "SELECT * FROM icinga_dbversion;" &> /dev/null', - command => 'mysql -uroot icinga2 < /usr/share/doc/icinga2-ido-mysql-$(rpm -q icinga2-ido-mysql | cut -d\'-\' -f4)/schema/mysql.sql', + command => 'mysql -uroot icinga2 < /usr/share/icinga2-ido-mysql/schema/mysql.sql', require => [ Exec['create-mysql-icinga2-db'], Package['icinga2-ido-mysql'] ] } @@ -577,7 +568,7 @@ populate_monitoring_test_config { ['commands', 'contacts', 'dependencies', } define populate_monitoring_test_config_plugins { - file { "/usr/lib64/nagios/plugins/libexec/${name}": + file { "/usr/lib64/nagios/plugins/${name}": owner => 'icinga', group => 'icinga', source => "/usr/local/share/misc/monitoring_test_config/plugins/${name}", @@ -795,3 +786,15 @@ file { '/etc/bash_completion.d/icingacli': require => Exec['install bash-completion'] } +file { '/etc/icingaweb/modules/doc/': + ensure => 'directory', + owner => 'apache', + group => 'apache' +} + +file { '/etc/icingaweb/modules/doc/menu.ini': + source => 'puppet:////vagrant/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini', + owner => 'apache', + group => 'apache', +} + diff --git a/.vagrant-puppet/modules/monitoring-plugins/manifests/init.pp b/.vagrant-puppet/modules/monitoring-plugins/manifests/init.pp new file mode 100644 index 000000000..6dc7be09d --- /dev/null +++ b/.vagrant-puppet/modules/monitoring-plugins/manifests/init.pp @@ -0,0 +1,9 @@ +class monitoring-plugins { + include epel + + # nagios plugins from epel + package { 'nagios-plugins-all': + ensure => installed, + require => Class['epel'] + } +} \ No newline at end of file diff --git a/application/controllers/AuthenticationController.php b/application/controllers/AuthenticationController.php index 7d4864d5a..4b43ed71b 100644 --- a/application/controllers/AuthenticationController.php +++ b/application/controllers/AuthenticationController.php @@ -35,12 +35,17 @@ class AuthenticationController extends ActionController public function loginAction() { $auth = $this->Auth(); - $this->view->form = new LoginForm(); - $this->view->form->setRequest($this->_request); + $this->view->form = $form = new LoginForm(); + $form->setRequest($this->_request); $this->view->title = $this->translate('Icingaweb Login'); try { - $redirectUrl = Url::fromPath($this->params->get('redirect', 'dashboard')); + $redirectUrl = $this->view->form->getValue('redirect'); + if ($redirectUrl) { + $redirectUrl = Url::fromPath($redirectUrl); + } else { + $redirectUrl = Url::fromPath('dashboard'); + } if ($auth->isAuthenticated()) { $this->rerenderLayout()->redirectNow($redirectUrl); @@ -49,13 +54,9 @@ class AuthenticationController extends ActionController try { $config = Config::app('authentication'); } catch (NotReadableError $e) { - Logger::error( - new Exception('Cannot load authentication configuration. An exception was thrown:', 0, $e) - ); throw new ConfigurationError( - t( - 'No authentication methods available. Authentication configuration could not be loaded.' - . ' Please check the system log or Icinga Web 2 log for more information' + $this->translate( + 'Could not read your authentiction.ini, no authentication methods are available.' ) ); } @@ -72,12 +73,20 @@ class AuthenticationController extends ActionController } } } - } elseif ($this->view->form->isSubmittedAndValid()) { - $user = new User($this->view->form->getValue('username')); - $password = $this->view->form->getValue('password'); + } elseif ($form->isSubmittedAndValid()) { + $user = new User($form->getValue('username')); + $password = $form->getValue('password'); $backendsTried = 0; $backendsWithError = 0; + $redirectUrl = $form->getValue('redirect'); + + if ($redirectUrl) { + $redirectUrl = Url::fromPath($redirectUrl); + } else { + $redirectUrl = Url::fromPath('dashboard'); + } + foreach ($chain as $backend) { if ($backend instanceof AutoLoginBackend) { continue; @@ -97,29 +106,29 @@ class AuthenticationController extends ActionController } if ($backendsTried === 0) { throw new ConfigurationError( - t( - 'No authentication methods available. It seems that no authentication method has been set' - . ' up. Please check the system log or Icinga Web 2 log for more information' - ) + $this->translate( + 'No authentication methods available. Did you create' + . ' authentication.ini when installing Icinga Web 2?' + ) ); } if ($backendsTried === $backendsWithError) { throw new ConfigurationError( $this->translate( - 'No authentication methods available. It seems that all set up authentication methods have' - . ' errors. Please check the system log or Icinga Web 2 log for more information' + 'All configured authentication methods failed.' + . ' Please check the system log or Icinga Web 2 log for more information.' ) ); } if ($backendsWithError) { - $this->view->form->addNote( + $form->addNote( $this->translate( - 'Note that not all authentication backends are available for authentication because they' - . ' have errors. Please check the system log or Icinga Web 2 log for more information' + 'Please note that not all authentication methods where available.' + . ' Check the system log or Icinga Web 2 log for more information.' ) ); } - $this->view->form->getElement('password')->addError($this->translate('Incorrect username or password')); + $form->getElement('password')->addError($this->translate('Incorrect username or password')); } } catch (Exception $e) { $this->view->errorInfo = $e->getMessage(); diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 31739c30e..45be1d3fd 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -48,9 +48,6 @@ class ConfigController extends BaseConfigController ))->add('logging', array( 'title' => 'Logging', 'url' => 'config/logging' - ))->add('modules', array( - 'title' => 'Modules', - 'url' => 'config/modules' )); } @@ -70,7 +67,7 @@ class ConfigController extends BaseConfigController $form->setConfiguration(IcingaConfig::app()); $form->setRequest($this->_request); if ($form->isSubmittedAndValid()) { - if (!$this->writeConfigFile($form->getConfig(), 'config')) { + if (!$this->writeConfigFile($form->getConfig(), 'config')) { return; } Notification::success('New configuration has successfully been stored'); @@ -107,6 +104,11 @@ class ConfigController extends BaseConfigController */ public function modulesAction() { + $this->view->tabs = Widget::create('tabs')->add('modules', array( + 'title' => 'Modules', + 'url' => 'config/modules' + )); + $this->view->tabs->activate('modules'); $this->view->modules = Icinga::app()->getModuleManager()->select() ->from('modules') diff --git a/application/controllers/LayoutController.php b/application/controllers/LayoutController.php index 2d1abfb72..8df5c2c21 100644 --- a/application/controllers/LayoutController.php +++ b/application/controllers/LayoutController.php @@ -18,7 +18,9 @@ class LayoutController extends ActionController */ public function menuAction() { - $this->view->menuRenderer = new MenuRenderer(Menu::fromConfig()->order(), Url::fromRequest()->getRelativeUrl()); + $this->view->menuRenderer = new MenuRenderer( + Menu::fromConfig()->order(), Url::fromRequest()->without('renderLayout')->getRelativeUrl() + ); } /** diff --git a/application/forms/Authentication/LoginForm.php b/application/forms/Authentication/LoginForm.php index cbe25b623..0eff8006c 100644 --- a/application/forms/Authentication/LoginForm.php +++ b/application/forms/Authentication/LoginForm.php @@ -5,6 +5,7 @@ namespace Icinga\Form\Authentication; use Icinga\Web\Form; +use Icinga\Web\Url; /** * Class LoginForm @@ -16,12 +17,19 @@ class LoginForm extends Form */ protected function create() { + $url = Url::fromRequest()->without('renderLayout'); + $this->setName('form_login'); $this->addElement('text', 'username', array( 'label' => t('Username'), 'placeholder' => t('Please enter your username...'), 'required' => true, )); + $redir = $this->addElement('hidden', 'redirect'); + $redirectUrl = $url->shift('redirect'); + if ($redirectUrl) { + $this->setDefault('redirect', $redirectUrl); + } $this->addElement('password', 'password', array( 'label' => t('Password'), @@ -34,6 +42,7 @@ class LoginForm extends Form } else { $this->getElement('username')->setAttrib('class', 'autofocus'); } + $this->setAction((string) $url); $this->setSubmitLabel('Login'); } } diff --git a/application/layouts/scripts/body.phtml b/application/layouts/scripts/body.phtml index be9531adc..a40715458 100644 --- a/application/layouts/scripts/body.phtml +++ b/application/layouts/scripts/body.phtml @@ -38,7 +38,7 @@ if ($notifications->hasMessages()) { </div> <?php endif ?> <div id="main" role="main"> - <div id="col1" class="container<?= $moduleClass ?>"<?php if ($moduleName): ?> data-icinga-module="<?= $moduleName ?>" <?php endif ?> data-icinga-url="<?= Url::fromRequest() ?>"<?= $refresh ?> style="display: block"> + <div id="col1" class="container<?= $moduleClass ?>"<?php if ($moduleName): ?> data-icinga-module="<?= $moduleName ?>" <?php endif ?> data-icinga-url="<?= Url::fromRequest()->without('renderLayout') ?>"<?= $refresh ?> style="display: block"> <?= $this->render('inline.phtml') ?> </div> <div id="col2" class="container"> diff --git a/application/layouts/scripts/layout.phtml b/application/layouts/scripts/layout.phtml index b4fd504ed..32c4879b4 100644 --- a/application/layouts/scripts/layout.phtml +++ b/application/layouts/scripts/layout.phtml @@ -44,6 +44,7 @@ $iframeClass = $isIframe ? ' iframe' : ''; <!--[if lt IE 9]> <script src="<?= $this->baseUrl('js/vendor/respond.min.js');?>"></script> <![endif]--> + <link type="image/png" rel="shortcut icon" href="<?= $this->baseUrl('img/favicon.png') ?>" /> </head> <body id="body"> diff --git a/application/layouts/scripts/parts/navigation.phtml b/application/layouts/scripts/parts/navigation.phtml index c689db54a..8b557e32f 100644 --- a/application/layouts/scripts/parts/navigation.phtml +++ b/application/layouts/scripts/parts/navigation.phtml @@ -14,5 +14,5 @@ if (! $this->auth()->isAuthenticated()) { <form action="<?= $this->href('search') ?>" method="get" role="search"> <input type="text" name="q" class="search autofocus" placeholder="<?= $this->translate('Search...') ?>" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> </form> -<?= new MenuRenderer(Menu::fromConfig()->order(), Url::fromRequest()->getRelativeUrl()); ?> +<?= new MenuRenderer(Menu::fromConfig()->order(), Url::fromRequest()->without('renderLayout')->getRelativeUrl()); ?> </div> diff --git a/config/menu.ini b/config/menu.ini index 65959c93a..075f4230e 100644 --- a/config/menu.ini +++ b/config/menu.ini @@ -18,10 +18,15 @@ title = "Configuration" url = "config" priority = 300 +[System.Modules] +title = "Modules" +url = "config/modules" +priority = 400 + [System.ApplicationLog] title = "Application log" url = "list/applicationlog" -priority = 400 +priority = 500 [Logout] url = "authentication/logout" diff --git a/config/modules/doc/menu.ini b/config/modules/doc/menu.ini new file mode 100644 index 000000000..86889b239 --- /dev/null +++ b/config/modules/doc/menu.ini @@ -0,0 +1,5 @@ +[Documentation] +title = "Documentation" +icon = "img/icons/comment.png" +url = "doc" +priority = 80 diff --git a/icingaweb2.spec b/icingaweb2.spec index 0da22b8ea..cfdcdafc2 100644 --- a/icingaweb2.spec +++ b/icingaweb2.spec @@ -40,11 +40,12 @@ %endif # SLE 11 = 1110 %if 0%{?suse_version} == 1110 +%define phpname php53 %define apache2modphpname apache2-mod_php53 %define usermodparam -A %endif -%if "%{_vendor}" == "redhat" || 0%{?suse_version} == 1110 +%if "%{_vendor}" == "redhat" %define phpname php %define phpzendname php-ZendFramework %endif diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php index 8dd189097..3d5e3752e 100644 --- a/library/Icinga/Application/Modules/Module.php +++ b/library/Icinga/Application/Modules/Module.php @@ -5,6 +5,7 @@ namespace Icinga\Application\Modules; use Exception; +use Zend_Controller_Router_Route_Abstract; use Zend_Controller_Router_Route as Route; use Icinga\Application\ApplicationBootstrap; use Icinga\Application\Config; @@ -135,6 +136,16 @@ class Module */ private $app; + + /** + * Routes to add to the route chain + * + * @var array Array of name-route pairs + * + * @see addRoute() + */ + protected $routes = array(); + /** * Create a new module object * @@ -166,8 +177,7 @@ class Module */ public function register() { - $this->registerAutoloader() - ->registerWebIntegration(); + $this->registerAutoloader(); try { $this->launchRunScript(); } catch (Exception $e) { @@ -179,6 +189,7 @@ class Module ); return false; } + $this->registerWebIntegration(); return true; } @@ -658,24 +669,29 @@ class Module } /** - * Register routes for web access + * Add routes for static content and any route added via addRoute() to the route chain * - * @return self + * @return self + * @see addRoute() */ protected function registerRoutes() { - $this->app->getFrontController()->getRouter()->addRoute( + $router = $this->app->getFrontController()->getRouter(); + foreach ($this->routes as $name => $route) { + $router->addRoute($name, $route); + } + $router->addRoute( $this->name . '_jsprovider', new Route( 'js/' . $this->name . '/:file', array( 'controller' => 'static', 'action' =>'javascript', - 'module_name' => $this->name + 'module_name' => $this->name ) ) ); - $this->app->getFrontController()->getRouter()->addRoute( + $router->addRoute( $this->name . '_img', new Route( 'img/' . $this->name . '/:file', @@ -750,4 +766,19 @@ class Module return $this; } + + /** + * Add a route which will be added to the route chain + * + * @param string $name Name of the route + * @param Zend_Controller_Router_Route_Abstract $route Instance of the route + * + * @return self + * @see registerRoutes() + */ + protected function addRoute($name, Zend_Controller_Router_Route_Abstract $route) + { + $this->routes[$name] = $route; + return $this; + } } diff --git a/library/Icinga/Cli/Command.php b/library/Icinga/Cli/Command.php index 081be318c..acb2462e4 100644 --- a/library/Icinga/Cli/Command.php +++ b/library/Icinga/Cli/Command.php @@ -7,6 +7,7 @@ namespace Icinga\Cli; use Icinga\Cli\Screen; use Icinga\Util\Translator; use Icinga\Cli\Params; +use Icinga\Application\Config; use Icinga\Application\ApplicationBootstrap as App; use Exception; @@ -23,6 +24,10 @@ abstract class Command protected $commandName; protected $actionName; + private $config; + + private $configs; + protected $defaultActionName = 'default'; public function __construct(App $app, $moduleName, $commandName, $actionName, $initialize = true) @@ -41,6 +46,51 @@ abstract class Command } } + public function Config($file = null) + { + if ($this->isModule()) { + return $this->getModuleConfig($file); + } else { + return $this->getMainConfig($file); + } + } + + private function getModuleConfig($file = null) + { + if ($file === null) { + if ($this->config === null) { + $this->config = Config::module($this->moduleName); + } + return $this->config; + } else { + if (! array_key_exists($file, $this->configs)) { + $this->configs[$file] = Config::module($this->moduleName, $file); + } + return $this->configs[$file]; + } + } + + private function getMainConfig($file = null) + { + if ($file === null) { + if ($this->config === null) { + $this->config = Config::app(); + } + return $this->config; + } else { + if (! array_key_exists($file, $this->configs)) { + $this->configs[$file] = Config::module($module, $file); + } + return $this->configs[$file]; + } + return $this->config; + } + + public function isModule() + { + return substr(get_class($this), 0, 14) === 'Icinga\\Module\\'; + } + public function setParams(Params $params) { $this->params = $params; diff --git a/library/Icinga/Data/Identifiable.php b/library/Icinga/Data/Identifiable.php new file mode 100644 index 000000000..cfa727a1d --- /dev/null +++ b/library/Icinga/Data/Identifiable.php @@ -0,0 +1,18 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Data; + +/** + * Interface for objects that are identifiable by an ID of any type + */ +interface Identifiable +{ + /** + * Get the ID associated with this Identifiable object + * + * @return mixed + */ + public function getId(); +} diff --git a/library/Icinga/Data/Tree/Node.php b/library/Icinga/Data/Tree/Node.php new file mode 100644 index 000000000..b57b0fad4 --- /dev/null +++ b/library/Icinga/Data/Tree/Node.php @@ -0,0 +1,79 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Data\Tree; + +use SplDoublyLinkedList; + +class Node extends SplDoublyLinkedList implements NodeInterface +{ + /** + * The node's value + * + * @var mixed + */ + protected $value; + + /** + * Create a new node + * + * @param mixed $value The node's value + */ + public function __construct($value = null) + { + $this->value = $value; + } + + /** + * Get the node's value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * Create a new node from the given value and insert the node as the last child of this node + * + * @param mixed $value The node's value + * + * @return NodeInterface The appended node + */ + public function appendChild($value) + { + $child = new static($value); + $this->push($child); + return $child; + } + + /** + * Whether this node has child nodes + * + * @return bool + */ + public function hasChildren() + { + $current = $this->current(); + if ($current === null) { + $current = $this; + } + return ! $current->isEmpty(); + } + + /** + * Get the node's child nodes + * + * @return NodeInterface + */ + public function getChildren() + { + $current = $this->current(); + if ($current === null) { + $current = $this; + } + return $current; + } +} diff --git a/library/Icinga/Data/Tree/NodeInterface.php b/library/Icinga/Data/Tree/NodeInterface.php new file mode 100644 index 000000000..6953214dc --- /dev/null +++ b/library/Icinga/Data/Tree/NodeInterface.php @@ -0,0 +1,26 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Data\Tree; + +use RecursiveIterator; + +interface NodeInterface extends RecursiveIterator +{ + /** + * Create a new node from the given value and insert the node as the last child of this node + * + * @param mixed $value The node's value + * + * @return NodeInterface The appended node + */ + public function appendChild($value); + + /** + * Get the node's value + * + * @return mixed + */ + public function getValue(); +} diff --git a/library/Icinga/Web/Controller/ActionController.php b/library/Icinga/Web/Controller/ActionController.php index 1c4111e4b..13809e83d 100644 --- a/library/Icinga/Web/Controller/ActionController.php +++ b/library/Icinga/Web/Controller/ActionController.php @@ -254,10 +254,27 @@ class ActionController extends Zend_Controller_Action * * @throws \Exception */ - protected function redirectToLogin($afterLogin = '/dashboard') + protected function redirectToLogin($afterLogin = null) { + $redir = null; + if ($afterLogin !== null) { + if (! $afterLogin instanceof Url) { + $afterLogin = Url::fromPath($afterLogin); + } + if ($this->isXhr()) { + $redir = '__SELF__'; + } else { + // TODO: Ignore /? + $redir = $afterLogin->getRelativeUrl(); + } + } + $url = Url::fromPath('authentication/login'); - $url->setParam('redirect', $afterLogin); + + if ($redir) { + $url->setParam('redirect', $redir); + } + $this->rerenderLayout()->redirectNow($url); } @@ -273,6 +290,27 @@ class ActionController extends Zend_Controller_Action return $this->getRequest()->isXmlHttpRequest(); } + protected function redirectXhr($url) + { + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + + if ($this->rerenderLayout) { + $this->getResponse()->setHeader('X-Icinga-Rerender-Layout', 'yes'); + } + if ($this->reloadCss) { + $this->getResponse()->setHeader('X-Icinga-Reload-Css', 'now'); + } + + $this->getResponse() + ->setHeader('X-Icinga-Redirect', rawurlencode($url->getAbsoluteUrl())) + ->sendHeaders(); + + // TODO: Session shutdown? + exit; + } + /** * Redirect to a specific url, updating the browsers URL field * @@ -280,26 +318,13 @@ class ActionController extends Zend_Controller_Action **/ public function redirectNow($url) { - if (! $url instanceof Url) { - $url = Url::fromPath($url); - } - $url = preg_replace('~&~', '&', $url); if ($this->isXhr()) { - if ($this->rerenderLayout) { - $this->getResponse()->setHeader('X-Icinga-Rerender-Layout', 'yes'); - } - if ($this->reloadCss) { - $this->getResponse()->setHeader('X-Icinga-Reload-Css', 'now'); - } - - $this->getResponse() - ->setHeader('X-Icinga-Redirect', rawurlencode($url)) - ->sendHeaders(); - - // TODO: Session shutdown? - exit; + $this->redirectXhr($url); } else { - $this->_helper->Redirector->gotoUrlAndExit(Url::fromPath($url)->getRelativeUrl()); + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + $this->_helper->Redirector->gotoUrlAndExit($url->getRelativeUrl()); } } diff --git a/library/Icinga/Web/Url.php b/library/Icinga/Web/Url.php index ef5a15aa9..ed3acf6a7 100644 --- a/library/Icinga/Web/Url.php +++ b/library/Icinga/Web/Url.php @@ -127,10 +127,6 @@ class Url $baseUrl = $request->getBaseUrl(); $urlObject->setBaseUrl($baseUrl); - // Fetch fragment manually and remove it from the url, to 'help' the parse_url() function - // parsing the url properly. Otherwise calling the function with a fragment, but without a - // query will cause unpredictable behaviour. - $fragment = self::stripUrlFragment($url); $urlParts = parse_url($url); if (isset($urlParts['path'])) { if ($baseUrl !== '' && strpos($urlParts['path'], $baseUrl) === 0) { @@ -144,29 +140,14 @@ class Url $params = UrlParams::fromQueryString($urlParts['query'])->mergeValues($params); } - if ($fragment) { - $urlObject->setAnchor($fragment); + if (isset($urlParts['fragment'])) { + $urlObject->setAnchor($urlParts['fragment']); } $urlObject->setParams($params); return $urlObject; } - /** - * Remove the fragment-part of a given url and return it - * - * @param string $url The url to strip its fragment from - * - * @return null|string The stripped fragment, without the '#' - */ - protected static function stripUrlFragment(&$url) - { - if (preg_match('@#(.*)$@', $url, $matches)) { - $url = str_replace('#' . $matches[1], '', $url); - return $matches[1]; - } - } - /** * Overwrite the baseUrl * @@ -226,12 +207,12 @@ class Url * * @return string */ - public function getRelativeUrl() + public function getRelativeUrl($separator = '&') { if ($this->params->isEmpty()) { return $this->path . $this->anchor; } else { - return $this->path . '?' . $this->params->setSeparator('&') . $this->anchor; + return $this->path . '?' . $this->params->toString($separator) . $this->anchor; } } @@ -251,9 +232,9 @@ class Url * * @return string */ - public function getAbsoluteUrl() + public function getAbsoluteUrl($separator = '&') { - return $this->baseUrl . ($this->baseUrl !== '/' ? '/' : '') . $this->getRelativeUrl(); + return $this->baseUrl . ($this->baseUrl !== '/' ? '/' : '') . $this->getRelativeUrl($separator); } /** @@ -435,6 +416,6 @@ class Url */ public function __toString() { - return $this->getAbsoluteUrl(); + return $this->getAbsoluteUrl('&'); } } diff --git a/library/Icinga/Web/UrlParams.php b/library/Icinga/Web/UrlParams.php index 11fed8333..bdf776401 100644 --- a/library/Icinga/Web/UrlParams.php +++ b/library/Icinga/Web/UrlParams.php @@ -114,6 +114,18 @@ class UrlParams return $ret; } + public function addEncoded($param, $value = true) + { + $this->params[] = array($param, $this->cleanupValue($value)); + $this->indexLastOne(); + return $this; + } + + protected function urlEncode($value) + { + return rawurlencode((string) $value); + } + /** * Add the given parameter with the given value * @@ -127,9 +139,7 @@ class UrlParams */ public function add($param, $value = true) { - $this->params[] = array($param, $this->cleanupValue($value)); - $this->indexLastOne(); - return $this; + return $this->addEncoded($this->urlEncode($param), $this->urlEncode($value)); } /** @@ -198,7 +208,7 @@ class UrlParams */ public function unshift($param, $value) { - array_unshift($this->params, array($param, $this->cleanupValue($value))); + array_unshift($this->params, array($this->urlEncode($param), $this->urlEncode($value))); $this->reIndexAll(); return $this; } @@ -224,7 +234,10 @@ class UrlParams unset($this->params[$remove]); } - $this->params[$this->index[$param][0]] = array($param, $this->cleanupValue($value)); + $this->params[$this->index[$param][0]] = array( + $this->urlEncode($param), + $this->urlEncode($this->cleanupValue($value)) + ); $this->reIndexAll(); return $this; @@ -243,7 +256,7 @@ class UrlParams foreach ($this->index[$p] as $key) { unset($this->params[$key]); } - $this->changed = true; + $changed = true; } } @@ -303,10 +316,10 @@ class UrlParams protected function parseQueryStringPart($part) { if (strpos($part, '=') === false) { - $this->add($part, true); + $this->addEncoded($part, true); } else { list($key, $val) = preg_split('/=/', $part, 2); - $this->add($key, $val); + $this->addEncoded($key, $val); } } @@ -315,8 +328,11 @@ class UrlParams return $this->params; } - public function __toString() + public function toString($separator = null) { + if ($separator === null) { + $separator = $this->separator; + } $parts = array(); foreach ($this->params as $p) { if ($p[1] === true) { @@ -325,13 +341,18 @@ class UrlParams $parts[] = $p[0] . '=' . $p[1]; } } - return implode($this->separator, $parts); + return implode($separator, $parts); + } + + public function __toString() + { + return $this->toString(); } public static function fromQueryString($queryString = null) { if ($queryString === null) { - $queryString = $_SERVER['QUERY_STRING']; + $queryString = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''; } $params = new static(); $params->parseQueryString($queryString); diff --git a/library/Icinga/Web/Widget/Limiter.php b/library/Icinga/Web/Widget/Limiter.php index 9c6a726ba..5afa7e4b8 100644 --- a/library/Icinga/Web/Widget/Limiter.php +++ b/library/Icinga/Web/Widget/Limiter.php @@ -81,7 +81,7 @@ class Limiter extends AbstractWidget $this->url->setParam('limit', $limit), null, array( - 'title' => t(sprintf('Show %s rows on one page', $caption)) + 'title' => sprintf(t('Show %s rows on one page'), $caption) ) ); } diff --git a/modules/doc/application/controllers/IcingawebController.php b/modules/doc/application/controllers/IcingawebController.php new file mode 100644 index 000000000..967a2b768 --- /dev/null +++ b/modules/doc/application/controllers/IcingawebController.php @@ -0,0 +1,48 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +use \Zend_Controller_Action_Exception; +use Icinga\Application\Icinga; +use Icinga\Module\Doc\DocController; + +class Doc_IcingawebController extends DocController +{ + /** + * View the toc of Icinga Web 2's documentation + */ + public function tocAction() + { + $this->renderToc(Icinga::app()->getApplicationDir('/../doc'), 'Icinga Web 2', 'doc/icingaweb/chapter'); + } + + /** + * View a chapter of Icinga Web 2's documentation + * + * @throws Zend_Controller_Action_Exception If the required parameter 'chapterId' is missing + */ + public function chapterAction() + { + $chapterId = $this->getParam('chapterId'); + if ($chapterId === null) { + throw new Zend_Controller_Action_Exception( + $this->translate('Missing parameter \'chapterId\''), + 404 + ); + } + $this->renderChapter( + Icinga::app()->getApplicationDir('/../doc'), + $chapterId, + 'doc/icingaweb/toc', + 'doc/icingaweb/chapter' + ); + } + + /** + * View Icinga Web 2's documentation as PDF + */ + public function pdfAction() + { + $this->renderPdf(Icinga::app()->getApplicationDir('/../doc'), 'Icinga Web 2', 'doc/icingaweb/chapter'); + } +} diff --git a/modules/doc/application/controllers/IndexController.php b/modules/doc/application/controllers/IndexController.php index f46fdad87..c83cfabab 100644 --- a/modules/doc/application/controllers/IndexController.php +++ b/modules/doc/application/controllers/IndexController.php @@ -2,34 +2,9 @@ // {{{ICINGA_LICENSE_HEADER}}} // {{{ICINGA_LICENSE_HEADER}}} -use Icinga\Module\Doc\Controller as DocController; - -use Icinga\Module\Doc\DocParser; +use Icinga\Module\Doc\DocController; class Doc_IndexController extends DocController { - protected $parser; - - - public function init() - { - $module = null; - $this->parser = new DocParser($module); - } - - - public function tocAction() - { - // Temporary workaround - list($html, $toc) = $this->parser->getDocumentation(); - $this->view->toc = $toc; - } - - /** - * Display the application's documentation - */ - public function indexAction() - { - $this->populateView(); - } + public function indexAction() {} } diff --git a/modules/doc/application/controllers/ModuleController.php b/modules/doc/application/controllers/ModuleController.php index 41ba42db6..40913368c 100644 --- a/modules/doc/application/controllers/ModuleController.php +++ b/modules/doc/application/controllers/ModuleController.php @@ -2,44 +2,131 @@ // {{{ICINGA_LICENSE_HEADER}}} // {{{ICINGA_LICENSE_HEADER}}} +use \Zend_Controller_Action_Exception; use Icinga\Application\Icinga; -use Icinga\Module\Doc\Controller as DocController; +use Icinga\Module\Doc\DocController; +use Icinga\Module\Doc\Exception\DocException; class Doc_ModuleController extends DocController { /** - * Display module documentations index + * List modules which are enabled and having the 'doc' directory */ public function indexAction() { - $this->view->enabledModules = Icinga::app()->getModuleManager()->listEnabledModules(); - } - - /** - * Display a module's documentation - */ - public function viewAction() - { - $this->populateView($this->getParam('name')); - } - - /** - * Provide run-time dispatching of module documentation - * - * @param string $methodName - * @param array $args - * - * @return mixed - */ - public function __call($methodName, $args) - { - // TODO(el): Setup routing to retrieve module name as param and point route to moduleAction - $moduleManager = Icinga::app()->getModuleManager(); - $moduleName = substr($methodName, 0, -6); // Strip 'Action' suffix - if (!$moduleManager->hasEnabled($moduleName)) { - // TODO(el): Throw a not found exception once the code has been moved to the moduleAction (see TODO above) - return parent::__call($methodName, $args); + $moduleManager = Icinga::app()->getModuleManager(); + $modules = array(); + foreach (Icinga::app()->getModuleManager()->listEnabledModules() as $enabledModule) { + $docDir = $moduleManager->getModuleDir($enabledModule, '/doc'); + if (is_dir($docDir)) { + $modules[] = $enabledModule; + } } - $this->_helper->redirector->gotoSimpleAndExit('view', null, null, array('name' => $moduleName)); + $this->view->modules = $modules; + } + + /** + * Assert that the given module is enabled + * + * @param $moduleName + * + * @throws Zend_Controller_Action_Exception If the required parameter 'moduleName' is empty or either if the + * given module is neither installed nor enabled + */ + protected function assertModuleEnabled($moduleName) + { + if (empty($moduleName)) { + throw new Zend_Controller_Action_Exception( + $this->translate('Missing parameter \'moduleName\''), + 404 + ); + } + $moduleManager = Icinga::app()->getModuleManager(); + if (! $moduleManager->hasInstalled($moduleName)) { + throw new Zend_Controller_Action_Exception( + sprintf($this->translate('Module \'%s\' is not installed'), $moduleName), + 404 + ); + } + if (! $moduleManager->hasEnabled($moduleName)) { + throw new Zend_Controller_Action_Exception( + sprintf($this->translate('Module \'%s\' is not enabled'), $moduleName), + 404 + ); + } + } + + /** + * View the toc of a module's documentation + * + * @see assertModuleEnabled() + */ + public function tocAction() + { + $moduleName = $this->getParam('moduleName'); + $this->assertModuleEnabled($moduleName); + $moduleManager = Icinga::app()->getModuleManager(); + try { + $this->renderToc( + $moduleManager->getModuleDir($moduleName, '/doc'), + $moduleName, + 'doc/module/chapter', + array('moduleName' => $moduleName) + ); + } catch (DocException $e) { + throw new Zend_Controller_Action_Exception($e->getMessage(), 404); + } + $this->view->moduleName = $moduleName; + } + + /** + * View a chapter of a module's documentation + * + * @throws Zend_Controller_Action_Exception If the required parameter 'chapterId' is missing or if an error in + * the documentation module's library occurs + * @see assertModuleEnabled() + */ + public function chapterAction() + { + $moduleName = $this->getParam('moduleName'); + $this->assertModuleEnabled($moduleName); + $chapterId = $this->getParam('chapterId'); + if ($chapterId === null) { + throw new Zend_Controller_Action_Exception( + $this->translate('Missing parameter \'chapterId\''), + 404 + ); + } + $moduleManager = Icinga::app()->getModuleManager(); + try { + $this->renderChapter( + $moduleManager->getModuleDir($moduleName, '/doc'), + $chapterId, + $this->_helper->url->url(array('moduleName' => $moduleName), 'doc/module/toc'), + 'doc/module/chapter', + array('moduleName' => $moduleName) + ); + } catch (DocException $e) { + throw new Zend_Controller_Action_Exception($e->getMessage(), 404); + } + $this->view->moduleName = $moduleName; + } + + /** + * View a module's documentation as PDF + * + * @see assertModuleEnabled() + */ + public function pdfAction() + { + $moduleName = $this->getParam('moduleName'); + $this->assertModuleEnabled($moduleName); + $moduleManager = Icinga::app()->getModuleManager(); + $this->renderPdf( + $moduleManager->getModuleDir($moduleName, '/doc'), + $moduleName, + 'doc/module/chapter', + array('moduleName' => $moduleName) + ); } } diff --git a/modules/doc/application/views/scripts/chapter.phtml b/modules/doc/application/views/scripts/chapter.phtml new file mode 100644 index 000000000..7657d69fb --- /dev/null +++ b/modules/doc/application/views/scripts/chapter.phtml @@ -0,0 +1,3 @@ +<div class="chapter"> + <?= $sectionRenderer->render($this, $this->getHelper('Url')); ?> +</div> diff --git a/modules/doc/application/views/scripts/index/index.phtml b/modules/doc/application/views/scripts/index/index.phtml index a178cc155..e4218bee2 100644 --- a/modules/doc/application/views/scripts/index/index.phtml +++ b/modules/doc/application/views/scripts/index/index.phtml @@ -1,5 +1,6 @@ -<h1>Icinga 2 Documentation</h1> -<?= $this->partial('module/view.phtml', 'doc', array( - 'toc' => $toc, - 'html' => $html -)); ?> \ No newline at end of file +<div class="controls"></div> +<h1><?= $this->translate('Available documentations'); ?></h1> +<ul> + <li><a href="<?= $this->href('doc/icingaweb/toc'); ?>">Icinga Web 2</a></li> + <li><a href="<?= $this->href('doc/module/'); ?>"><?= $this->translate('Module documentations'); ?></a></li> +</ul> diff --git a/modules/doc/application/views/scripts/index/toc.phtml b/modules/doc/application/views/scripts/index/toc.phtml deleted file mode 100644 index 9188e21ff..000000000 --- a/modules/doc/application/views/scripts/index/toc.phtml +++ /dev/null @@ -1,14 +0,0 @@ -<div class="controls"> -<h1>Module documentations</h1> -</div> -<div class="content" data-base-target="_next"> -<?= $this->partial( - 'layout/menu.phtml', - 'default', - array( - 'items' => $toc->getChildren(), - 'sub' => false, - 'url' => '' - ) -) ?> -</div> diff --git a/modules/doc/application/views/scripts/module/index.phtml b/modules/doc/application/views/scripts/module/index.phtml index 36f11e15e..cc184016f 100644 --- a/modules/doc/application/views/scripts/module/index.phtml +++ b/modules/doc/application/views/scripts/module/index.phtml @@ -1,6 +1,10 @@ -<h1>Module documentations</h1> +<h1><?= $this->translate('Module documentations'); ?></h1> <ul> -<?php foreach ($enabledModules as $module): ?> - <li><a href="<?= $this->href('doc/module/view', array('name' => $module)); ?>"><?= $module ?></a></li> -<?php endforeach ?> + <?php foreach ($modules as $module): ?> + <li> + <a href="<?= $this->getHelper('Url')->url(array('moduleName' => $module), 'doc/module/toc', false, false); ?>"> + <?= $module ?> + </a> + </li> + <?php endforeach ?> </ul> diff --git a/modules/doc/application/views/scripts/module/view.phtml b/modules/doc/application/views/scripts/module/view.phtml deleted file mode 100644 index 291947ad7..000000000 --- a/modules/doc/application/views/scripts/module/view.phtml +++ /dev/null @@ -1,7 +0,0 @@ -<?php if ($html === null): ?> - <p>No documentation available.</p> -<?php else: ?> -<div class="content"> -<?= $html ?> -</div> -<?php endif ?> diff --git a/modules/doc/application/views/scripts/pdf.phtml b/modules/doc/application/views/scripts/pdf.phtml new file mode 100644 index 000000000..72d77f3c0 --- /dev/null +++ b/modules/doc/application/views/scripts/pdf.phtml @@ -0,0 +1,7 @@ +<h1><?= $docName ?> <?= $this->translate('Documentation'); ?></h1> +<div class="toc"> + <?= $tocRenderer->render($this, $this->getHelper('Url')); ?> +</div> +<div class="chapter"> + <?= $sectionRenderer->render($this, $this->getHelper('Url')); ?> +</div> diff --git a/modules/doc/application/views/scripts/toc.phtml b/modules/doc/application/views/scripts/toc.phtml new file mode 100644 index 000000000..ca6283d67 --- /dev/null +++ b/modules/doc/application/views/scripts/toc.phtml @@ -0,0 +1,6 @@ +<div class="controls"> + <h1><?= $title ?></h1> +</div> +<div class="content toc"> + <?= $tocRenderer->render($this, $this->getHelper('Url')); ?> +</div> diff --git a/modules/doc/library/Doc/Controller.php b/modules/doc/library/Doc/Controller.php deleted file mode 100644 index 2c5a07d49..000000000 --- a/modules/doc/library/Doc/Controller.php +++ /dev/null @@ -1,23 +0,0 @@ -<?php -// {{{ICINGA_LICENSE_HEADER}}} -// {{{ICINGA_LICENSE_HEADER}}} - -namespace Icinga\Module\Doc; - -use Icinga\Web\Controller\ModuleActionController; - -class Controller extends ModuleActionController -{ - /** - * Set HTML and toc - * - * @param string $module - */ - protected function populateView($module = null) - { - $parser = new DocParser($module); - list($html, $toc) = $parser->getDocumentation(); - $this->view->html = $html; - $this->view->toc = $toc; - } -} diff --git a/modules/doc/library/Doc/DocController.php b/modules/doc/library/Doc/DocController.php new file mode 100644 index 000000000..42f8dce9b --- /dev/null +++ b/modules/doc/library/Doc/DocController.php @@ -0,0 +1,76 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc; + +use Icinga\Web\Controller\ModuleActionController; + +class DocController extends ModuleActionController +{ + /** + * Render a chapter + * + * @param string $path Path to the documentation + * @param string $chapterId ID of the chapter + * @param string $tocUrl + * @param string $url + * @param array $urlParams + */ + protected function renderChapter($path, $chapterId, $tocUrl, $url, array $urlParams = array()) + { + $parser = new DocParser($path); + $this->view->sectionRenderer = new SectionRenderer( + $parser->getDocTree(), + SectionRenderer::decodeUrlParam($chapterId), + $tocUrl, + $url, + $urlParams + ); + $this->view->title = $chapterId; + $this->_helper->viewRenderer('chapter', null, true); + } + + /** + * Render a toc + * + * @param string $path Path to the documentation + * @param string $name Name of the documentation + * @param string $url + * @param array $urlParams + */ + protected function renderToc($path, $name, $url, array $urlParams = array()) + { + $parser = new DocParser($path); + $this->view->tocRenderer = new TocRenderer($parser->getDocTree(), $url, $urlParams); + $name = ucfirst($name); + $this->view->docName = $name; + $this->view->title = sprintf($this->translate('%s Documentation'), $name); + $this->_helper->viewRenderer('toc', null, true); + } + + /** + * Render a pdf + * + * @param string $path Path to the documentation + * @param string $name Name of the documentation + * @param string $url + * @param array $urlParams + */ + protected function renderPdf($path, $name, $url, array $urlParams = array()) + { + $parser = new DocParser($path); + $docTree = $parser->getDocTree(); + $this->view->tocRenderer = new TocRenderer($docTree, $url, $urlParams); + $this->view->sectionRenderer = new SectionRenderer( + $docTree, + null, + null, + $url, + $urlParams + ); + $this->view->docName = $name; + $this->_helper->viewRenderer('pdf', null, true); + $this->_request->setParam('format', 'pdf'); + } +} diff --git a/modules/doc/library/Doc/DocException.php b/modules/doc/library/Doc/DocException.php deleted file mode 100644 index cb7134045..000000000 --- a/modules/doc/library/Doc/DocException.php +++ /dev/null @@ -1,11 +0,0 @@ -<?php -// {{{ICINGA_LICENSE_HEADER}}} -// {{{ICINGA_LICENSE_HEADER}}} - -namespace Icinga\Module\Doc; - -use \Exception; - -class DocException extends Exception -{ -} diff --git a/modules/doc/library/Doc/DocIterator.php b/modules/doc/library/Doc/DocIterator.php new file mode 100644 index 000000000..43a9c7727 --- /dev/null +++ b/modules/doc/library/Doc/DocIterator.php @@ -0,0 +1,62 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc; + +use ArrayIterator; +use Countable; +use IteratorAggregate; +use RecursiveIteratorIterator; +use RecursiveDirectoryIterator; + +/** + * Iterator over non-empty Markdown files ordered by the case insensitive "natural order" of file names + */ +class DocIterator implements Countable, IteratorAggregate +{ + /** + * Ordered files + * + * @var array + */ + protected $fileInfo; + + /** + * Create a new DocIterator + * + * @param string $path Path to the documentation + */ + public function __construct($path) + { + $it = new RecursiveIteratorIterator( + new NonEmptyFileIterator( + new MarkdownFileIterator( + new RecursiveDirectoryIterator($path) + ) + ) + ); + // Unfortunately we have no chance to sort the iterator + $fileInfo = iterator_to_array($it); + natcasesort($fileInfo); + $this->fileInfo = $fileInfo; + } + + /** + * (non-PHPDoc) + * @see Countable::count() + */ + public function count() + { + return count($this->fileInfo); + } + + /** + * (non-PHPDoc) + * @see IteratorAggregate::getIterator() + */ + public function getIterator() + { + return new ArrayIterator($this->fileInfo); + } +} diff --git a/modules/doc/library/Doc/DocParser.php b/modules/doc/library/Doc/DocParser.php index d4e6875d1..c63532dc1 100644 --- a/modules/doc/library/Doc/DocParser.php +++ b/modules/doc/library/Doc/DocParser.php @@ -4,146 +4,65 @@ namespace Icinga\Module\Doc; -use RecursiveIteratorIterator; -use RecursiveDirectoryIterator; -use Parsedown; -use Icinga\Application\Icinga; -use Icinga\Web\Menu; -use Icinga\Web\Url; - -require_once 'IcingaVendor/Parsedown/Parsedown.php'; +use SplDoublyLinkedList; +use Icinga\Exception\NotReadableError; +use Icinga\Module\Doc\Exception\DocEmptyException; +use Icinga\Module\Doc\Exception\DocException; /** * Parser for documentation written in Markdown */ class DocParser { - protected $dir; - - protected $module; + /** + * Path to the documentation + * + * @var string + */ + protected $path; /** - * Create a new documentation parser for the given module or the application + * Iterator over documentation files * - * @param string $module - * - * @throws DocException + * @var DocIterator */ - public function __construct($module = null) - { - if ($module === null) { - $dir = Icinga::app()->getApplicationDir('/../doc'); - } else { - $mm = Icinga::app()->getModuleManager(); - if (!$mm->hasInstalled($module)) { - throw new DocException('Module is not installed'); - } - if (!$mm->hasEnabled($module)) { - throw new DocException('Module is not enabled'); - } - $dir = $mm->getModuleDir($module, '/doc'); - } - if (!is_dir($dir)) { - throw new DocException('Doc directory does not exist'); - } - $this->dir = $dir; - $this->module = $module; - } + protected $docIterator; /** - * Retrieve table of contents and HTML converted from markdown files sorted by filename + * Create a new documentation parser for the given path * - * @return array - * @throws DocException + * @param string $path Path to the documentation + * + * @throws DocException If the documentation directory does not exist + * @throws NotReadableError If the documentation directory is not readable + * @throws DocEmptyException If the documentation directory is empty */ - public function getDocumentation() + public function __construct($path) { - $iter = new RecursiveIteratorIterator( - new MarkdownFileIterator( - new RecursiveDirectoryIterator($this->dir) - ) - ); - $fileInfos = iterator_to_array($iter); - natcasesort($fileInfos); - $cat = array(); - $toc = array((object) array( - 'level' => 0, - 'item' => new Menu('doc') - )); - $itemPriority = 1; - foreach ($fileInfos as $fileInfo) { - try { - $fileObject = $fileInfo->openFile(); - } catch (RuntimeException $e) { - throw new DocException($e->getMessage()); - } - if ($fileObject->flock(LOCK_SH) === false) { - throw new DocException('Couldn\'t get the lock'); - } - $line = null; - while (!$fileObject->eof()) { - // Save last line for setext-style headers - $lastLine = $line; - $line = $fileObject->fgets(); - $header = $this->extractHeader($line, $lastLine); - if ($header !== null) { - list($header, $level) = $header; - $id = $this->extractHeaderId($header); - $attribs = array(); - $this->reduceToc($toc, $level); - if ($id === null) { - $path = array(); - foreach (array_slice($toc, 1) as $entry) { - $path[] = $entry->item->getTitle(); - } - $path[] = $header; - $id = implode('-', $path); - $attribs['rel'] = 'nofollow'; - } - $id = urlencode(str_replace('.', '.', strip_tags($id))); - $item = end($toc)->item->addChild( - $id, - array( - 'url' => Url::fromPath( - 'doc/module/view', - array( - 'name' => $this->module - ) - )->setAnchor($id)->getRelativeUrl(), - 'title' => htmlspecialchars($header), - 'priority' => $itemPriority++, - 'attribs' => $attribs - ) - ); - $toc[] = ((object) array( - 'level' => $level, - 'item' => $item - )); - $line = '<a name="' . $id . '"></a>' . PHP_EOL . $line; - } - $cat[] = $line; - } - $fileObject->flock(LOCK_UN); + if (! is_dir($path)) { + throw new DocException( + sprintf(mt('doc', 'Documentation directory \'%s\' does not exist'), $path) + ); } - $html = Parsedown::instance()->parse(implode('', $cat)); - $html = preg_replace_callback( - '#<pre><code class="language-php">(.*?)\</code></pre>#s', - array($this, 'highlight'), - $html - ); - return array($html, $toc[0]->item); - } - - /** - * Syntax highlighting for PHP code - * - * @param $match - * - * @return string - */ - protected function highlight($match) - { - return highlight_string(htmlspecialchars_decode($match[1]), true); + if (! is_readable($path)) { + throw new DocException( + sprintf(mt('doc', 'Documentation directory \'%s\' is not readable'), $path) + ); + } + $docIterator = new DocIterator($path); + if ($docIterator->count() === 0) { + throw new DocEmptyException( + sprintf( + mt( + 'doc', + 'Documentation directory \'%s\' does not contain any non-empty Markdown file (\'.md\' suffix)' + ), + $path + ) + ); + } + $this->path = $path; + $this->docIterator = $docIterator; } /** @@ -156,28 +75,28 @@ class DocParser */ protected function extractHeader($line, $lastLine) { - if (!$line) { + if (! $line) { return null; } $header = null; - if ($line && - $line[0] === '#' && - preg_match('/^#+/', $line, $match) === 1 + if ($line + && $line[0] === '#' + && preg_match('/^#+/', $line, $match) === 1 ) { - // Atx-style + // Atx $level = strlen($match[0]); $header = trim(substr($line, $level)); - if (!$header) { + if (! $header) { return null; } } elseif ( - $line && - ($line[0] === '=' || $line[0] === '-') && - preg_match('/^[=-]+\s*$/', $line, $match) === 1 + $line + && ($line[0] === '=' || $line[0] === '-') + && preg_match('/^[=-]+\s*$/', $line, $match) === 1 ) { // Setext $header = trim($lastLine); - if (!$header) { + if (! $header) { return null; } if ($match[0][0] === '=') { @@ -189,36 +108,67 @@ class DocParser if ($header === null) { return null; } - return array($header, $level); - } - - /** - * Extract header id in an a or a span tag - * - * @param string &$header - * - * @return id|null - */ - protected function extractHeaderId(&$header) - { - if ($header[0] === '<' && - preg_match('#(?:<(?P<tag>a|span) id="(?P<id>.+)"></(?P=tag)>)#u', $header, $match) + if ($header[0] === '<' + && preg_match('#(?:<(?P<tag>a|span) (?:id|name)="(?P<id>.+)"></(?P=tag)>)\s*#u', $header, $match) ) { $header = str_replace($match[0], '', $header); - return $match['id']; + $id = $match['id']; + } else { + $id = null; } - return null; + return array($header, $id, $level); } /** - * Reduce the toc to the given level + * Get the documentation tree * - * @param array &$toc - * @param int $level + * @return DocTree */ - protected function reduceToc(array &$toc, $level) { - while (end($toc)->level >= $level) { - array_pop($toc); + public function getDocTree() + { + $tree = new DocTree(); + $stack = new SplDoublyLinkedList(); + foreach ($this->docIterator as $fileInfo) { + /* @var $file \SplFileInfo */ + $file = $fileInfo->openFile(); + /* @var $file \SplFileObject */ + $lastLine = null; + foreach ($file as $line) { + $header = $this->extractHeader($line, $lastLine); + if ($header !== null) { + list($title, $id, $level) = $header; + while (! $stack->isEmpty() && $stack->top()->getLevel() >= $level) { + $stack->pop(); + } + if ($id === null) { + $path = array(); + foreach ($stack as $section) { + /* @var $section Section */ + $path[] = $section->getTitle(); + } + $path[] = $title; + $id = implode('-', $path); + $noFollow = true; + } else { + $noFollow = false; + } + if ($stack->isEmpty()) { + $chapterId = $id; + $section = new Section($id, $title, $level, $noFollow, $chapterId); + $tree->addRoot($section); + } else { + $chapterId = $stack->bottom()->getId(); + $section = new Section($id, $title, $level, $noFollow, $chapterId); + $tree->addChild($section, $stack->top()); + } + $stack->push($section); + } else { + $stack->top()->appendContent($line); + } + // Save last line for setext-style headers + $lastLine = $line; + } } + return $tree; } } diff --git a/modules/doc/library/Doc/DocTree.php b/modules/doc/library/Doc/DocTree.php new file mode 100644 index 000000000..1b112649c --- /dev/null +++ b/modules/doc/library/Doc/DocTree.php @@ -0,0 +1,80 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc; + +use LogicException; +use Icinga\Data\Identifiable; +use Icinga\Data\Tree\Node; + +/** + * Documentation tree + */ +class DocTree extends Node +{ + /** + * All nodes of the tree + * + * @var array + */ + protected $nodes = array(); + + /** + * Append a root node to the tree + * + * @param Identifiable $root + */ + public function addRoot(Identifiable $root) + { + $rootId = $root->getId(); + if (isset($this->nodes[$rootId])) { + $rootId = uniqid($rootId); +// throw new LogicException( +// sprintf('Can\'t add root node: a root node with the id \'%s\' already exists', $rootId) +// ); + } + $this->nodes[$rootId] = $this->appendChild($root); + } + + /** + * Append a child node to a parent node + * + * @param Identifiable $child + * @param Identifiable $parent + * + * @throws LogicException If the the tree does not contain the parent node + */ + public function addChild(Identifiable $child, Identifiable $parent) + { + $childId = $child->getId(); + $parentId = $parent->getId(); + if (isset($this->nodes[$childId])) { + $childId = uniqid($childId); +// throw new LogicException( +// sprintf('Can\'t add child node: a child node with the id \'%s\' already exists', $childId) +// ); + } + if (! isset($this->nodes[$parentId])) { + throw new LogicException( + sprintf(mt('doc', 'Can\'t add child node: there\'s no parent node having the id \'%s\''), $parentId) + ); + } + $this->nodes[$childId] = $this->nodes[$parentId]->appendChild($child); + } + + /** + * Get a node + * + * @param mixed $id + * + * @return Node|null + */ + public function getNode($id) + { + if (! isset($this->nodes[$id])) { + return null; + } + return $this->nodes[$id]; + } +} diff --git a/modules/doc/library/Doc/Exception/ChapterNotFoundException.php b/modules/doc/library/Doc/Exception/ChapterNotFoundException.php new file mode 100644 index 000000000..cd048a162 --- /dev/null +++ b/modules/doc/library/Doc/Exception/ChapterNotFoundException.php @@ -0,0 +1,10 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc\Exception; + +/** + * Exception thrown if a chapter was not found + */ +class ChapterNotFoundException extends DocException {} diff --git a/modules/doc/library/Doc/Exception/DocEmptyException.php b/modules/doc/library/Doc/Exception/DocEmptyException.php new file mode 100644 index 000000000..2869e2678 --- /dev/null +++ b/modules/doc/library/Doc/Exception/DocEmptyException.php @@ -0,0 +1,10 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc\Exception; + +/** + * Exception thrown if a documentation directory is empty + */ +class DocEmptyException extends DocException {} diff --git a/modules/doc/library/Doc/Exception/DocException.php b/modules/doc/library/Doc/Exception/DocException.php new file mode 100644 index 000000000..374ea3e01 --- /dev/null +++ b/modules/doc/library/Doc/Exception/DocException.php @@ -0,0 +1,12 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc\Exception; + +use RuntimeException; + +/** + * Exception thrown if an error in the documentation module's library occurs + */ +class DocException extends RuntimeException {} diff --git a/modules/doc/library/Doc/MarkdownFileIterator.php b/modules/doc/library/Doc/MarkdownFileIterator.php index 1a8f1c53c..6f317ce6a 100644 --- a/modules/doc/library/Doc/MarkdownFileIterator.php +++ b/modules/doc/library/Doc/MarkdownFileIterator.php @@ -4,15 +4,15 @@ namespace Icinga\Module\Doc; -use \RecursiveFilterIterator; +use RecursiveFilterIterator; /** - * Iterator over Markdown files recursively + * Recursive iterator over Markdown files */ class MarkdownFileIterator extends RecursiveFilterIterator { /** - * Accept files with .md suffix + * Accept files with '.md' suffix * * @return bool Whether the current element of the iterator is acceptable * through this filter @@ -20,7 +20,8 @@ class MarkdownFileIterator extends RecursiveFilterIterator public function accept() { $current = $this->getInnerIterator()->current(); - if (!$current->isFile()) { + /* @var $current \SplFileInfo */ + if (! $current->isFile()) { return false; } $filename = $current->getFilename(); diff --git a/modules/doc/library/Doc/NonEmptyFileIterator.php b/modules/doc/library/Doc/NonEmptyFileIterator.php new file mode 100644 index 000000000..71bf5acfa --- /dev/null +++ b/modules/doc/library/Doc/NonEmptyFileIterator.php @@ -0,0 +1,31 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}} + +namespace Icinga\Module\Doc; + +use RecursiveFilterIterator; + +/** + * Recursive iterator over non-empty files + */ +class NonEmptyFileIterator extends RecursiveFilterIterator +{ + /** + * Accept non-empty files + * + * @return bool Whether the current element of the iterator is acceptable + * through this filter + */ + public function accept() + { + $current = $this->getInnerIterator()->current(); + /* @var $current \SplFileInfo */ + if (! $current->isFile() + || $current->getSize() === 0 + ) { + return false; + } + return true; + } +} diff --git a/modules/doc/library/Doc/Renderer.php b/modules/doc/library/Doc/Renderer.php new file mode 100644 index 000000000..0aebb89b9 --- /dev/null +++ b/modules/doc/library/Doc/Renderer.php @@ -0,0 +1,75 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc; + +use RecursiveIteratorIterator; +use Zend_View_Helper_Url; +use Icinga\Web\View; + +/** + * Base class for toc and section renderer + */ +abstract class Renderer extends RecursiveIteratorIterator +{ + /** + * Encode an anchor identifier + * + * @param string $anchor + * + * @return string + */ + public static function encodeAnchor($anchor) + { + return rawurlencode($anchor); + } + + /** + * Decode an anchor identifier + * + * @param string $anchor + * + * @return string + */ + public static function decodeAnchor($anchor) + { + return rawurldecode($anchor); + } + + /** + * Encode a URL parameter + * + * @param string $param + * + * @return string + */ + public static function encodeUrlParam($param) + { + return str_replace(array('%2F','%5C'), array('%252F','%255C'), rawurlencode($param)); + } + + /** + * Decode a URL parameter + * + * @param string $param + * + * @return string + */ + public static function decodeUrlParam($param) + { + return str_replace(array('%2F', '%5C'), array('/', '\\'), $param); + } + + /** + * Render to HTML + * + * Meant to be overwritten by concrete classes. + * + * @param View $view + * @param Zend_View_Helper_Url $zendUrlHelper + * + * @return string + */ + abstract public function render(View $view, Zend_View_Helper_Url $zendUrlHelper); +} diff --git a/modules/doc/library/Doc/Section.php b/modules/doc/library/Doc/Section.php new file mode 100644 index 000000000..5cf3d61e2 --- /dev/null +++ b/modules/doc/library/Doc/Section.php @@ -0,0 +1,143 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Doc; + +use Icinga\Data\Identifiable; + +/** + * A section of a documentation + */ +class Section implements Identifiable +{ + /** + * The ID of the section + * + * @var string + */ + protected $id; + + /** + * The title of the section + * + * @var string + */ + protected $title; + + /** + * The header level + * + * @var int + */ + protected $level; + + /** + * Whether to instruct search engines to not index the link to the section + * + * @var bool + */ + protected $noFollow; + + /** + * The ID of the chapter the section is part of + * + * @var string + */ + protected $chapterId; + + /** + * The content of the section + * + * @var array + */ + protected $content = array(); + + /** + * Create a new section + * + * @param string $id The ID of the section + * @param string $title The title of the section + * @param int $level The header level + * @param bool $noFollow Whether to instruct search engines to not index the link to the section + * @param string $chapterId The ID of the chapter the section is part of + */ + public function __construct($id, $title, $level, $noFollow, $chapterId) + { + $this->id = $id; + $this->title = $title; + $this->level = $level; + $this->noFollow = $noFollow; + $this->chapterId= $chapterId; + } + + /** + * Get the ID of the section + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Get the title of the section + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Get the header level + * + * @return int + */ + public function getLevel() + { + return $this->level; + } + + /** + * Whether to instruct search engines to not index the link to the section + * + * @return bool + */ + public function isNoFollow() + { + return $this->noFollow; + } + + /** + * The ID of the chapter the section is part of + * + * @return string + */ + public function getChapterId() + { + return $this->chapterId; + } + + /** + * Append content + * + * @param string $content + */ + public function appendContent($content) + { + $this->content[] = $content; + } + + /** + * Get the content of the section + * + * @return array + */ + public function getContent() + { + return $this->content; + } +} diff --git a/modules/doc/library/Doc/SectionFilterIterator.php b/modules/doc/library/Doc/SectionFilterIterator.php new file mode 100644 index 000000000..e20d80359 --- /dev/null +++ b/modules/doc/library/Doc/SectionFilterIterator.php @@ -0,0 +1,68 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}} + +namespace Icinga\Module\Doc; + +use Countable; +use RecursiveFilterIterator; +use Icinga\Data\Tree\NodeInterface; + +/** + * Recursive iterator over sections that are part of a particular chapter + */ +class SectionFilterIterator extends RecursiveFilterIterator implements Countable +{ + /** + * The chapter ID to filter for + * + * @var string + */ + protected $chapterId; + + /** + * Create a new SectionFilterIterator + * + * @param NodeInterface $node Node + * @param string $chapterId The chapter ID to filter for + */ + public function __construct(NodeInterface $node, $chapterId) + { + parent::__construct($node); + $this->chapterId = $chapterId; + } + + /** + * Accept sections that are part of the given chapter + * + * @return bool Whether the current element of the iterator is acceptable + * through this filter + */ + public function accept() + { + $section = $this->getInnerIterator()->current()->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + if ($section->getChapterId() === $this->chapterId) { + return true; + } + return false; + } + + /** + * (non-PHPDoc) + * @see RecursiveFilterIterator::getChildren() + */ + public function getChildren() + { + return new static($this->getInnerIterator()->getChildren(), $this->chapterId); + } + + /** + * (non-PHPDoc) + * @see Countable::count() + */ + public function count() + { + return iterator_count($this); + } +} diff --git a/modules/doc/library/Doc/SectionRenderer.php b/modules/doc/library/Doc/SectionRenderer.php new file mode 100644 index 000000000..938e5ed7b --- /dev/null +++ b/modules/doc/library/Doc/SectionRenderer.php @@ -0,0 +1,292 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}} + +namespace Icinga\Module\Doc; + +require_once 'IcingaVendor/Parsedown/Parsedown.php'; + +use DOMDocument; +use DOMXPath; +use RecursiveIteratorIterator; +use Parsedown; +use Zend_View_Helper_Url; +use Icinga\Module\Doc\Exception\ChapterNotFoundException; +use Icinga\Web\Url; +use Icinga\Web\View; + +/** + * preg_replace_callback helper to replace links + */ +class Callback +{ + protected $docTree; + + protected $view; + + protected $zendUrlHelper; + + protected $url; + + protected $urlParams; + + public function __construct( + DocTree $docTree, + View $view, + Zend_View_Helper_Url $zendUrlHelper, + $url, + array $urlParams) + { + $this->docTree = $docTree; + $this->view = $view; + $this->zendUrlHelper = $zendUrlHelper; + $this->url = $url; + $this->urlParams = $urlParams; + } + + public function render($match) + { + $node = $this->docTree->getNode(Renderer::decodeAnchor($match['fragment'])); + /* @var $node \Icinga\Data\Tree\Node */ + if ($node === null) { + return $match[0]; + } + $section = $node->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + $path = $this->zendUrlHelper->url( + array_merge( + $this->urlParams, + array( + 'chapterId' => SectionRenderer::encodeUrlParam($section->getChapterId()) + ) + ), + $this->url, + false, + false + ); + $url = $this->view->url($path); + $url->setAnchor(SectionRenderer::encodeAnchor($section->getId())); + return sprintf( + '<a %s%shref="%s"', + strlen($match['attribs']) ? trim($match['attribs']) . ' ' : '', + $section->isNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl() + ); + } +} + +/** + * Section renderer + */ +class SectionRenderer extends Renderer +{ + /** + * The documentation tree + * + * @var DocTree + */ + protected $docTree; + + protected $tocUrl; + + /** + * The URL to replace links with + * + * @var string + */ + protected $url; + + /** + * Additional URL parameters + * + * @var array + */ + protected $urlParams; + + /** + * Parsedown instance + * + * @var Parsedown + */ + protected $parsedown; + + /** + * Content + * + * @var array + */ + protected $content = array(); + + /** + * Create a new section renderer + * + * @param DocTree $docTree The documentation tree + * @param string|null $chapterId If not null, the chapter ID to filter for + * @param string $tocUrl + * @param string $url The URL to replace links with + * @param array $urlParams Additional URL parameters + * + * @throws ChapterNotFoundException If the chapter to filter for was not found + */ + public function __construct(DocTree $docTree, $chapterId, $tocUrl, $url, array $urlParams) + { + if ($chapterId !== null) { + $filter = new SectionFilterIterator($docTree, $chapterId); + if ($filter->count() === 0) { + throw new ChapterNotFoundException( + sprintf(mt('doc', 'Chapter \'%s\' not found'), $chapterId) + ); + } + parent::__construct( + $filter, + RecursiveIteratorIterator::SELF_FIRST + ); + } else { + parent::__construct($docTree, RecursiveIteratorIterator::SELF_FIRST); + } + $this->docTree = $docTree; + $this->tocUrl = $tocUrl; + $this->url = $url; + $this->urlParams = array_map(array($this, 'encodeUrlParam'), $urlParams); + $this->parsedown = Parsedown::instance(); + } + + /** + * Syntax highlighting for PHP code + * + * @param $match + * + * @return string + */ + protected function highlightPhp($match) + { + return '<pre>' . highlight_string(htmlspecialchars_decode($match[1]), true) . '</pre>'; + } + + /** + * Replace img src tags + * + * @param $match + * + * @return string + */ + protected function replaceImg($match) + { + $doc = new DOMDocument(); + $doc->loadHTML($match[0]); + $xpath = new DOMXPath($doc); + $img = $xpath->query('//img[1]')->item(0); + /* @var $img \DOMElement */ + $img->setAttribute('src', Url::fromPath($img->getAttribute('src'))->getAbsoluteUrl()); + return substr_replace($doc->saveXML($img), '', -2, 1); // Replace '/>' with '>' + } + + /** + * Render the section + * + * @param View $view + * @param Zend_View_Helper_Url $zendUrlHelper + * @param bool $renderNavigation + * + * @return string + */ + public function render(View $view, Zend_View_Helper_Url $zendUrlHelper, $renderNavigation = true) + { + $callback = new Callback($this->docTree, $view, $zendUrlHelper, $this->url, $this->urlParams); + $content = array(); + foreach ($this as $node) { + $section = $node->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + $content[] = sprintf( + '<a name="%1$s"></a><h%2$d>%3$s</h%2$d>', + Renderer::encodeAnchor($section->getId()), + $section->getLevel(), + $view->escape($section->getTitle()) + ); + $html = preg_replace_callback( + '#<pre><code class="language-php">(.*?)</code></pre>#s', + array($this, 'highlightPhp'), + $this->parsedown->text(implode('', $section->getContent())) + ); + $html = preg_replace_callback( + '/<img[^>]+>/', + array($this, 'replaceImg'), + $html + ); + $content[] = preg_replace_callback( + '/<a\s+(?P<attribs>[^>]*?\s+)?href="#(?P<fragment>[^"]+)"/', + array($callback, 'render'), + $html + ); + } + if ($renderNavigation) { + foreach ($this->docTree as $chapter) { + if ($chapter->getValue()->getId() === $section->getChapterId()) { + $navigation = array('<ul class="navigation">'); + $this->docTree->prev(); + $prev = $this->docTree->current(); + if ($prev !== null) { + $prev = $prev->getValue(); + $path = $zendUrlHelper->url( + array_merge( + $this->urlParams, + array( + 'chapterId' => $this->encodeUrlParam($prev->getChapterId()) + ) + ), + $this->url, + false, + false + ); + $url = $view->url($path); + $url->setAnchor($this->encodeAnchor($prev->getId())); + $navigation[] = sprintf( + '<li class="prev"><a %shref="%s">%s</a></li>', + $prev->isNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl(), + $view->escape($prev->getTitle()) + ); + $this->docTree->next(); + $this->docTree->next(); + } else { + $this->docTree->rewind(); + $this->docTree->next(); + } + $url = $view->url($this->tocUrl); + $navigation[] = sprintf( + '<li><a href="%s">%s</a></li>', + $url->getAbsoluteUrl(), + mt('doc', 'Index') + ); + $next = $this->docTree->current(); + if ($next !== null) { + $next = $next->getValue(); + $path = $zendUrlHelper->url( + array_merge( + $this->urlParams, + array( + 'chapterId' => $this->encodeUrlParam($next->getChapterId()) + ) + ), + $this->url, + false, + false + ); + $url = $view->url($path); + $url->setAnchor($this->encodeAnchor($next->getId())); + $navigation[] = sprintf( + '<li class="next"><a %shref="%s">%s</a></li>', + $next->isNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl(), + $view->escape($next->getTitle()) + ); + } + $navigation[] = '</ul>'; + $content = array_merge($navigation, $content, $navigation); + break; + } + } + } + return implode("\n", $content); + } +} diff --git a/modules/doc/library/Doc/TocRenderer.php b/modules/doc/library/Doc/TocRenderer.php new file mode 100644 index 000000000..4061e80e3 --- /dev/null +++ b/modules/doc/library/Doc/TocRenderer.php @@ -0,0 +1,109 @@ +<?php +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}} + +namespace Icinga\Module\Doc; + +use RecursiveIteratorIterator; +use Zend_View_Helper_Url; +use Icinga\Web\View; + +/** + * TOC renderer + */ +class TocRenderer extends Renderer +{ + /** + * The URL to replace links with + * + * @var string + */ + protected $url; + + /** + * Additional URL parameters + * + * @var array + */ + protected $urlParams; + + /** + * Content + * + * @var array + */ + protected $content = array(); + + /** + * Create a new toc renderer + * + * @param DocTree $docTree The documentation tree + * @param string $url The URL to replace links with + * @param array $urlParams Additional URL parameters + */ + public function __construct(DocTree $docTree, $url, array $urlParams) + { + parent::__construct($docTree, RecursiveIteratorIterator::SELF_FIRST); + $this->url = $url; + $this->urlParams = array_map(array($this, 'encodeUrlParam'), $urlParams); + } + + public function beginIteration() + { + $this->content[] = '<nav><ul>'; + } + + public function endIteration() + { + $this->content[] = '</ul></nav>'; + } + + public function beginChildren() + { + $this->content[] = '<ul>'; + } + + public function endChildren() + { + $this->content[] = '</ul></li>'; + } + + /** + * Render the toc + * + * @param View $view + * @param Zend_View_Helper_Url $zendUrlHelper + * + * @return string + */ + public function render(View $view, Zend_View_Helper_Url $zendUrlHelper) + { + foreach ($this as $node) { + $section = $node->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + $path = $zendUrlHelper->url( + array_merge( + $this->urlParams, + array( + 'chapterId' => $this->encodeUrlParam($section->getChapterId()) + ) + ), + $this->url, + false, + false + ); + $url = $view->url($path); + $url->setAnchor($this->encodeAnchor($section->getId())); + $this->content[] = sprintf( + '<li><a %shref="%s">%s</a>', + $section->isNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl(), + $view->escape($section->getTitle()) + ); + if (! $this->getInnerIterator()->current()->hasChildren()) { + $this->content[] = '</li>'; + } + } + return implode("\n", $this->content); + } +} diff --git a/modules/doc/public/css/module.less b/modules/doc/public/css/module.less new file mode 100644 index 000000000..d6d0d2a94 --- /dev/null +++ b/modules/doc/public/css/module.less @@ -0,0 +1,62 @@ +// W3C Recommendation <http://www.w3.org/TR/CSS21/sample.html> (except h4) +h1 { font-size: 2em !important; } +h2 { font-size: 1.5em !important; } +h3 { font-size: 1.17em !important; } +h4 { font-size: 1em !important; } +h5 { font-size: .83em !important; } +h6 { font-size: .75em !important; } + +div.chapter { + padding-left: 5px; +} + +table th { + text-align: left; +} + +table th, +table td { + border: solid 1px lightgray; + padding-left: 5px; + padding-right: 5px; +} + +code { + width: 100%; + overflow-x: auto; + padding: 0.2em; + display: inline; +} + +pre > code { + display: inline-block; +} + +div.chapter > ul.navigation { + margin: 0; + padding: 0.4em; + text-align: center; + background-color: #888; + + li { + list-style: none; + display: inline; + margin: 0.2em; + padding: 0; + + a { + color: #fff; + text-decoration: none; + } + + &.prev { + padding-right: 0.6em; + border-right: 2px solid #fff; + } + + &.next { + padding-left: 0.6em; + border-left: 2px solid #fff; + } + } +} diff --git a/modules/doc/run.php b/modules/doc/run.php new file mode 100644 index 000000000..7392e4c22 --- /dev/null +++ b/modules/doc/run.php @@ -0,0 +1,50 @@ +<?php + +use \Zend_Controller_Router_Route; +use Icinga\Application\Icinga; + +if (Icinga::app()->isCli()) { + return; +} + +$docModuleChapter = new Zend_Controller_Router_Route( + 'doc/module/:moduleName/chapter/:chapterId', + array( + 'controller' => 'module', + 'action' => 'chapter', + 'module' => 'doc' + ) +); + +$docIcingaWebChapter = new Zend_Controller_Router_Route( + 'doc/icingaweb/chapter/:chapterId', + array( + 'controller' => 'icingaweb', + 'action' => 'chapter', + 'module' => 'doc' + ) +); + +$docModuleToc = new Zend_Controller_Router_Route( + 'doc/module/:moduleName/toc', + array( + 'controller' => 'module', + 'action' => 'toc', + 'module' => 'doc' + ) +); + +$docModulePdf = new Zend_Controller_Router_Route( + 'doc/module/:moduleName/pdf', + array( + 'controller' => 'module', + 'action' => 'pdf', + 'module' => 'doc' + ) +); + +$this->addRoute('doc/module/chapter', $docModuleChapter); +$this->addRoute('doc/icingaweb/chapter', $docIcingaWebChapter); +$this->addRoute('doc/module/toc', $docModuleToc); +$this->addRoute('doc/module/pdf', $docModulePdf); + diff --git a/modules/monitoring/application/controllers/ListController.php b/modules/monitoring/application/controllers/ListController.php index 6c4cc0101..3e02a4757 100644 --- a/modules/monitoring/application/controllers/ListController.php +++ b/modules/monitoring/application/controllers/ListController.php @@ -94,7 +94,6 @@ class Monitoring_ListController extends Controller 'host_last_check', 'host_last_state_change' => $stateChangeColumn, 'host_notifications_enabled', - // 'host_unhandled_service_count', 'host_unhandled_services', 'host_action_url', 'host_notes_url', diff --git a/modules/monitoring/application/controllers/MultiController.php b/modules/monitoring/application/controllers/MultiController.php index a623dd88b..6459edd59 100644 --- a/modules/monitoring/application/controllers/MultiController.php +++ b/modules/monitoring/application/controllers/MultiController.php @@ -24,7 +24,6 @@ class Monitoring_MultiController extends Controller array( 'host_name', 'host_in_downtime', - 'host_unhandled_service_count', 'host_passive_checks_enabled', 'host_obsessing', 'host_state', diff --git a/public/img/favicon.png b/public/img/favicon.png new file mode 100644 index 000000000..6d6f71398 Binary files /dev/null and b/public/img/favicon.png differ diff --git a/public/js/helpers.js b/public/js/helpers.js index 082689e40..416bf1185 100644 --- a/public/js/helpers.js +++ b/public/js/helpers.js @@ -95,6 +95,13 @@ if (!Function.prototype.bind) { 'use strict'; + /* Whether a HTML tag has a specific attribute */ + $.fn.hasAttr = function(name) { + // We have inconsistent behaviour across browsers (false VS undef) + var val = this.attr(name); + return typeof val !== 'undefined' && val !== false; + }; + /* Get class list */ $.fn.classes = function (callback) { diff --git a/public/js/icinga/events.js b/public/js/icinga/events.js index 6c6978577..d04912d0f 100644 --- a/public/js/icinga/events.js +++ b/public/js/icinga/events.js @@ -474,12 +474,20 @@ return true; } - // Ignore clicks on multiselect table inner links while key pressed - if ((event.ctrlKey || event.metaKey || event.shiftKey) && - ! $a.is('tr[href]') && $a.closest('table.multiselect').length > 0 && - $a.closest('tr[href]').length > 0) - { - return self.rowSelected.call($a.closest('tr[href]'), event); + // Special checks for link clicks in multiselect rows + if (! $a.is('tr[href]') && $a.closest('tr[href]').length > 0 && $a.closest('table.multiselect').length > 0) { + + // Forward clicks to ANY link with special key pressed to rowSelected + if (event.ctrlKey || event.metaKey || event.shiftKey) + { + return self.rowSelected.call($a.closest('tr[href]'), event); + } + + // Forward inner links matching the row URL to rowSelected + if ($a.attr('href') === $a.closest('tr[href]').attr('href')) + { + return self.rowSelected.call($a.closest('tr[href]'), event); + } } // Let remote links pass through @@ -493,6 +501,12 @@ return false; } + // Ignore form elements in action rows + if ($(event.target).is('input') || $(event.target).is('button')) { + return; + } + + // ignore multiselect table row clicks if ($a.is('tr') && $a.closest('table.multiselect').length > 0) { return; diff --git a/public/js/icinga/history.js b/public/js/icinga/history.js index dded86379..a3fcf5deb 100644 --- a/public/js/icinga/history.js +++ b/public/js/icinga/history.js @@ -162,11 +162,18 @@ parts = document.location.hash.split(/#!/); - if ($('#col2').data('icingaUrl') !== main) { - icinga.loader.loadUrl( - parts[1], - $('#col2') - ).historyTriggered = true; + if ($('#layout > #login').length) { + // We are on the login page! + $('#login form #redirect').val( + $('#login form #redirect').val() + '#!' + parts[1] + ); + } else { + if ($('#col2').data('icingaUrl') !== main) { + icinga.loader.loadUrl( + parts[1], + $('#col2') + ).historyTriggered = true; + } } // TODO: Replace with dynamic columns diff --git a/public/js/icinga/loader.js b/public/js/icinga/loader.js index b871b4362..f91aa20b9 100644 --- a/public/js/icinga/loader.js +++ b/public/js/icinga/loader.js @@ -250,14 +250,24 @@ var icinga = this.icinga; var redirect = req.getResponseHeader('X-Icinga-Redirect'); if (! redirect) return false; + redirect = decodeURIComponent(redirect); + if (redirect.match(/__SELF__/)) { + redirect = redirect.replace(/__SELF__/, encodeURIComponent(document.location.pathname + document.location.search + document.location.hash)); + } icinga.logger.debug( 'Got redirect for ', req.$target, ', URL was ' + redirect ); - redirect = decodeURIComponent(redirect); if (req.getResponseHeader('X-Icinga-Rerender-Layout')) { + var parts = redirect.split(/#!/); + redirect = parts.shift(); var redirectionUrl = this.addUrlFlag(redirect, 'renderLayout'); - this.loadUrl(redirectionUrl, $('#layout')).url = redirect; + var r = this.loadUrl(redirectionUrl, $('#layout')); + r.url = redirect; + if (parts.length) { + r.loadNext = parts; + } + } else { if (req.$target.attr('id') === 'col2') { // TODO: multicol if ($('#col1').data('icingaUrl') === redirect) { @@ -556,6 +566,17 @@ req.$target.data('lastUpdate', (new Date()).getTime()); delete this.requests[req.$target.attr('id')]; this.icinga.ui.fadeNotificationsAway(); + + + if (typeof req.loadNext !== 'undefined') { + if ($('#col2').length) { + this.loadUrl(req.loadNext[0], $('#col2')); + this.icinga.ui.layout2col(); + } else { + this.icinga.logger.error('Failed to load URL for #col2', req.loadNext); + } + } + this.icinga.ui.refreshDebug(); }, diff --git a/public/js/icinga/ui.js b/public/js/icinga/ui.js index e9f3d741c..8ff3733f0 100644 --- a/public/js/icinga/ui.js +++ b/public/js/icinga/ui.js @@ -100,7 +100,7 @@ icinga.logger.info('Reloading CSS'); $('link').each(function() { var $oldLink = $(this); - if ($oldLink.attr('type').indexOf('css') > -1) { + if ($oldLink.hasAttr('type') && $oldLink.attr('type').indexOf('css') > -1) { var $newLink = $oldLink.clone().attr( 'href', icinga.utils.addUrlParams( diff --git a/test/php/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorderTest.php b/test/php/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorderTest.php index d9eb0b25b..a77dcb553 100644 --- a/test/php/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorderTest.php +++ b/test/php/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorderTest.php @@ -19,8 +19,8 @@ class SlidingwithborderTest extends BaseTestCase $pages = $scrollingStyle->getPages($paginator); $this->assertInternalType('array', $pages); - $this->assertCount(13, $pages); - $this->assertEquals('...', $pages[11]); + $this->assertCount(10, $pages); + $this->assertEquals('...', $pages[8]); } public function testGetPages3() @@ -31,9 +31,9 @@ class SlidingwithborderTest extends BaseTestCase $pages = $scrollingStyle->getPages($paginator); $this->assertInternalType('array', $pages); - $this->assertCount(16, $pages); + $this->assertCount(10, $pages); $this->assertEquals('...', $pages[3]); - $this->assertEquals('...', $pages[14]); + $this->assertEquals('...', $pages[12]); } protected function getPaginatorAdapter() diff --git a/test/php/library/Icinga/Web/UrlTest.php b/test/php/library/Icinga/Web/UrlTest.php index 0eff54e5c..183f33369 100644 --- a/test/php/library/Icinga/Web/UrlTest.php +++ b/test/php/library/Icinga/Web/UrlTest.php @@ -21,7 +21,7 @@ class UrlTest extends BaseTestCase $url = Url::fromRequest(); $this->assertEquals( '/path/to/my/test/url.html?param1=value1&param2=value2', - $url->getAbsoluteUrl(), + $url->getAbsoluteUrl('&'), 'Url::fromRequest does not reassemble the correct url from the global request' ); } @@ -119,7 +119,7 @@ class UrlTest extends BaseTestCase */ public function testWhetherFromPathProperlyRecognizesAndDecodesQueryParameters() { - $url = Url::fromPath('/my/test/url.html?param1=%25arg1¶m2=arg+2' + $url = Url::fromPath('/my/test/url.html?param1=%25arg1¶m2=arg%202' . '¶m3[]=1¶m3[]=2¶m3[]=3¶m4[key1]=val1¶m4[key2]=val2'); $this->assertEquals(