mirror of
https://github.com/Icinga/icingaweb2.git
synced 2025-07-21 12:54:26 +02:00
Change url handling to detail on hashtag, add service filter
The url is now http://%mainUrl%#%anchor%!detail=%detailUrl% which allows us to better support IE and prevents the detail url from appearing on the server side. refs #4868
This commit is contained in:
parent
3df8cacea8
commit
4a95ba3468
@ -33,9 +33,6 @@ use Icinga\Filter\Filter;
|
|||||||
use Icinga\Filter\FilterAttribute;
|
use Icinga\Filter\FilterAttribute;
|
||||||
use Icinga\Filter\Type\TextFilter;
|
use Icinga\Filter\Type\TextFilter;
|
||||||
use Icinga\Application\Logger;
|
use Icinga\Application\Logger;
|
||||||
use Icinga\Module\Monitoring\Filter\Type\StatusFilter;
|
|
||||||
use Icinga\Module\Monitoring\Filter\UrlViewFilter;
|
|
||||||
use Icinga\Module\Monitoring\DataView\HostStatus;
|
|
||||||
use Icinga\Web\Url;
|
use Icinga\Web\Url;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,6 +47,8 @@ class FilterController extends ActionController
|
|||||||
*/
|
*/
|
||||||
private $registry;
|
private $registry;
|
||||||
|
|
||||||
|
private $moduleRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point for filtering, uses the filter_domain and filter_module request parameter
|
* Entry point for filtering, uses the filter_domain and filter_module request parameter
|
||||||
* to determine which filter registry should be used
|
* to determine which filter registry should be used
|
||||||
@ -57,19 +56,27 @@ class FilterController extends ActionController
|
|||||||
public function indexAction()
|
public function indexAction()
|
||||||
{
|
{
|
||||||
$this->registry = new Filter();
|
$this->registry = new Filter();
|
||||||
|
$query = $this->getRequest()->getParam('query', '');
|
||||||
|
$target = $this->getRequest()->getParam('filter_domain', '');
|
||||||
|
|
||||||
if ($this->getRequest()->getHeader('accept') == 'application/json') {
|
if ($this->getRequest()->getHeader('accept') == 'application/json') {
|
||||||
$this->getResponse()->setHeader('Content-Type', 'application/json');
|
$this->getResponse()->setHeader('Content-Type', 'application/json');
|
||||||
|
|
||||||
$this->setupQueries(
|
$this->setupQueries(
|
||||||
$this->getParam('filter_domain', ''),
|
$target,
|
||||||
$this->getParam('filter_module', '')
|
$this->getParam('filter_module', '')
|
||||||
);
|
);
|
||||||
|
$this->_helper->json($this->parse($query, $target));
|
||||||
$this->_helper->json($this->parse($this->getRequest()->getParam('query', '')));
|
|
||||||
} else {
|
} else {
|
||||||
$this->redirect('index/welcome');
|
$this->setupQueries(
|
||||||
|
$target,
|
||||||
|
$this->getParam('filter_module')
|
||||||
|
);
|
||||||
|
$urlTarget = $this->parse($query, $target);
|
||||||
|
die(print_r($urlTarget,true));
|
||||||
|
$this->redirect($urlTarget['urlParam']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,6 +89,7 @@ class FilterController extends ActionController
|
|||||||
{
|
{
|
||||||
$class = '\\Icinga\\Module\\' . ucfirst($module) . '\\Filter\\Registry';
|
$class = '\\Icinga\\Module\\' . ucfirst($module) . '\\Filter\\Registry';
|
||||||
$factory = strtolower($domain) . 'Filter';
|
$factory = strtolower($domain) . 'Filter';
|
||||||
|
$this->moduleRegistry = $class;
|
||||||
$this->registry->addDomain($class::$factory());
|
$this->registry->addDomain($class::$factory());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,17 +99,16 @@ class FilterController extends ActionController
|
|||||||
* @param String $text The query to parse
|
* @param String $text The query to parse
|
||||||
* @return array The result structure to be returned in json format
|
* @return array The result structure to be returned in json format
|
||||||
*/
|
*/
|
||||||
private function parse($text)
|
private function parse($text, $target)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$view = HostStatus::fromRequest($this->getRequest());
|
|
||||||
$urlParser = new UrlViewFilter($view);
|
|
||||||
$queryTree = $this->registry->createQueryTreeForFilter($text);
|
|
||||||
|
|
||||||
|
$queryTree = $this->registry->createQueryTreeForFilter($text);
|
||||||
|
$registry = $this->moduleRegistry;
|
||||||
return array(
|
return array(
|
||||||
'state' => 'success',
|
'state' => 'success',
|
||||||
'proposals' => $this->registry->getProposalsForQuery($text),
|
'proposals' => $this->registry->getProposalsForQuery($text),
|
||||||
'urlParam' => $urlParser->fromTree($queryTree)
|
'urlParam' => $registry::getUrlForTarget($target, $queryTree)
|
||||||
);
|
);
|
||||||
} catch (\Exception $exc) {
|
} catch (\Exception $exc) {
|
||||||
Logger::error($exc);
|
Logger::error($exc);
|
||||||
|
@ -31,6 +31,8 @@
|
|||||||
/**
|
/**
|
||||||
* Helper to render main and detail contents into a container
|
* Helper to render main and detail contents into a container
|
||||||
*/
|
*/
|
||||||
|
use Icinga\Application\Icinga;
|
||||||
|
|
||||||
class Zend_View_Helper_MainDetail extends Zend_View_Helper_Abstract
|
class Zend_View_Helper_MainDetail extends Zend_View_Helper_Abstract
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
@ -31,10 +31,10 @@ $modules = $this->modules->paginate();
|
|||||||
<td>
|
<td>
|
||||||
<? if ($module->enabled): ?>
|
<? if ($module->enabled): ?>
|
||||||
<i>{{OK_ICON}}</i>
|
<i>{{OK_ICON}}</i>
|
||||||
<a href="<?= $disableUrl ?>"><?= $this->escape($module->name); ?></a>
|
<a href="<?= $disableUrl ?>" data-icinga-target="main"><?= $this->escape($module->name); ?></a>
|
||||||
<? else: ?>
|
<? else: ?>
|
||||||
<i>{{REMOVE_ICON}}</i>
|
<i>{{REMOVE_ICON}}</i>
|
||||||
<a href="<?= $enableUrl ?>"><?= $this->escape($module->name); ?></a>
|
<a href="<?= $enableUrl ?>" data-icinga-target="main"><?= $this->escape($module->name); ?></a>
|
||||||
<? endif ?>
|
<? endif ?>
|
||||||
(<?=
|
(<?=
|
||||||
$module->enabled ? ($module->loaded ? 'enabled' : 'failed') : 'disabled' ?>)
|
$module->enabled ? ($module->loaded ? 'enabled' : 'failed') : 'disabled' ?>)
|
||||||
|
@ -72,16 +72,10 @@ class Datasource implements DatasourceInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$result = array();
|
$result = array();
|
||||||
$filters = $query->listFilters();
|
|
||||||
$columns = $query->getColumns();
|
$columns = $query->getColumns();
|
||||||
foreach ($this->data as & $row) {
|
foreach ($this->data as & $row) {
|
||||||
|
|
||||||
// Skip rows that do not match applied filters
|
|
||||||
foreach ($filters as $f) {
|
|
||||||
if ($row->{$f[0]} !== $f[1]) {
|
|
||||||
continue 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get only desired columns if asked so
|
// Get only desired columns if asked so
|
||||||
if (empty($columns)) {
|
if (empty($columns)) {
|
||||||
|
@ -29,11 +29,41 @@
|
|||||||
|
|
||||||
namespace Icinga\Filter;
|
namespace Icinga\Filter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for filterable data sources
|
||||||
|
*/
|
||||||
interface Filterable
|
interface Filterable
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Return true when this field is filterable, otherwise false
|
||||||
|
*
|
||||||
|
* @param string $field The field to test for being filterable
|
||||||
|
* @return boolean True when this field is filterable, otherwise false
|
||||||
|
*/
|
||||||
public function isValidFilterTarget($field);
|
public function isValidFilterTarget($field);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the internal, resolved name of the given field
|
||||||
|
*
|
||||||
|
* @param string $field The field to resolve
|
||||||
|
* @return string The resolved name or null if the field is not resolvable
|
||||||
|
*/
|
||||||
public function getMappedField($field);
|
public function getMappedField($field);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all filters of this filterable on the datasource
|
||||||
|
*/
|
||||||
public function applyFilter();
|
public function applyFilter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all filters from this datasource
|
||||||
|
*/
|
||||||
public function clearFilter();
|
public function clearFilter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a filter to this datasource
|
||||||
|
*
|
||||||
|
* @param mixed $filter The filter to use
|
||||||
|
*/
|
||||||
public function addFilter($filter);
|
public function addFilter($filter);
|
||||||
}
|
}
|
||||||
|
43
library/Icinga/Filter/Registry.php
Normal file
43
library/Icinga/Filter/Registry.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
// {{{ICINGA_LICENSE_HEADER}}}
|
||||||
|
/**
|
||||||
|
* This file is part of Icinga 2 Web.
|
||||||
|
*
|
||||||
|
* Icinga 2 Web - Head for multiple monitoring backends.
|
||||||
|
* Copyright (C) 2013 Icinga Development Team
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 2
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
*
|
||||||
|
* @copyright 2013 Icinga Development Team <info@icinga.org>
|
||||||
|
* @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
|
||||||
|
* @author Icinga Development Team <info@icinga.org>
|
||||||
|
*/
|
||||||
|
// {{{ICINGA_LICENSE_HEADER}}}
|
||||||
|
|
||||||
|
|
||||||
|
namespace Icinga\Filter;
|
||||||
|
|
||||||
|
|
||||||
|
use Icinga\Filter\Query\Tree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for filter registries
|
||||||
|
* Class Registry
|
||||||
|
* @package Icinga\Filter
|
||||||
|
*/
|
||||||
|
interface Registry
|
||||||
|
{
|
||||||
|
public static function getUrlForTarget($domain, Tree $filter);
|
||||||
|
}
|
@ -106,23 +106,6 @@ class ActionController extends Zend_Controller_Action
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dispatchDetailView($url)
|
|
||||||
{
|
|
||||||
// strip the base URL from the detail $url
|
|
||||||
$url = substr($url, strlen($this->getRequest()->getBaseUrl()));
|
|
||||||
// the host is mandatory, but ignored in Zend
|
|
||||||
$req = new Request('http://ignoredhost/' . $url);
|
|
||||||
$req->setUser($this->getRequest()->getUser());
|
|
||||||
$req->setBaseUrl($this->getRequest()->getBaseUrl());
|
|
||||||
$router = Zend_Controller_Front::getInstance()->getRouter();
|
|
||||||
$router->route($req);
|
|
||||||
Zend_Controller_Front::getInstance()->setRequest($req);
|
|
||||||
$detailHtml = $this->view->action($req->getActionName(), $req->getControllerName(), $req->getModuleName());
|
|
||||||
Zend_Controller_Front::getInstance()->setRequest($this->getRequest());
|
|
||||||
$this->_helper->layout->assign('detailContent', $detailHtml);
|
|
||||||
$this->_helper->layout->assign('detailClass', 'col-sm-12 col-xs-12 col-md-12 col-lg-6');
|
|
||||||
$this->_helper->layout->assign('mainClass', 'col-sm-12 col-xs-12 col-md-12 col-lg-6');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the controller requires a login. That is when the controller requires authentication and the
|
* Check whether the controller requires a login. That is when the controller requires authentication and the
|
||||||
@ -223,30 +206,9 @@ class ActionController extends Zend_Controller_Action
|
|||||||
Benchmark::measure('Action::postDispatch()');
|
Benchmark::measure('Action::postDispatch()');
|
||||||
|
|
||||||
if ($this->_request->isXmlHttpRequest()) {
|
if ($this->_request->isXmlHttpRequest()) {
|
||||||
$this->_helper->layout()->setLayout('body');
|
$target = ($this->getParam('render') === 'detail') ? 'inline' : 'body';
|
||||||
|
$this->_helper->layout()->setLayout($target);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->getParam('detail', false)) {
|
|
||||||
$detail = $this->getParam('detail');
|
|
||||||
|
|
||||||
// Zend uses the GET variables when calling getParam, therefore we have to persist the params,
|
|
||||||
// clear the $_GET array, call the detail view with the url set in $detail and afterwards recreate
|
|
||||||
// the $_GET array. If this is not done the following issues occur:
|
|
||||||
//
|
|
||||||
// - A stackoverflow issue due to infinite nested calls of buildDetailView (as the detailview has the same
|
|
||||||
// postDispatch method) when 'detail' is not set to null
|
|
||||||
//
|
|
||||||
// - Params (like filters in the URL) from the detail view would be applied on all links of the master view
|
|
||||||
// as those would be in the $_GET array after building the detail view. E.g. if you have a grid in the
|
|
||||||
// master and a detail view filtering showing one host in detail, the pagination links of the grid would
|
|
||||||
// contain the host filter of the detail view
|
|
||||||
//
|
|
||||||
$params = $_GET;
|
|
||||||
$_GET['detail'] = null;
|
|
||||||
$this->dispatchDetailView($detail);
|
|
||||||
$_GET = $params;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -62,4 +62,6 @@ class Request extends Zend_Controller_Request_Http
|
|||||||
{
|
{
|
||||||
return $this->user;
|
return $this->user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -75,7 +75,7 @@ class FilterBadgeRenderer implements Widget
|
|||||||
$url = $this->urlFilter->fromTree($newTree);
|
$url = $this->urlFilter->fromTree($newTree);
|
||||||
$url = $basePath . (empty($allParams) ? '?' : '&') . $url;
|
$url = $basePath . (empty($allParams) ? '?' : '&') . $url;
|
||||||
|
|
||||||
return ' <a class="btn btn-default btn-xs" href="' . $url . '">'
|
return ' <a class="filter-badge btn btn-default btn-xs" href="' . $url . '">'
|
||||||
. $this->conjunctionCellar . ' '
|
. $this->conjunctionCellar . ' '
|
||||||
. ucfirst($node->left) . ' '
|
. ucfirst($node->left) . ' '
|
||||||
. $node->operator . ' '
|
. $node->operator . ' '
|
||||||
|
@ -99,10 +99,10 @@ EOT;
|
|||||||
$form->setTokenDisabled();
|
$form->setTokenDisabled();
|
||||||
$form->addElement(
|
$form->addElement(
|
||||||
'text',
|
'text',
|
||||||
'filter',
|
'query',
|
||||||
array(
|
array(
|
||||||
'label' => 'Filter Results',
|
'label' => 'Filter Results',
|
||||||
'name' => 'filter',
|
'name' => 'query',
|
||||||
'data-icinga-component' => 'app/semanticsearch',
|
'data-icinga-component' => 'app/semanticsearch',
|
||||||
'data-icinga-filter-domain' => $this->domain,
|
'data-icinga-filter-domain' => $this->domain,
|
||||||
'data-icinga-filter-module' => $this->module
|
'data-icinga-filter-module' => $this->module
|
||||||
@ -111,7 +111,6 @@ EOT;
|
|||||||
$form->removeAttrib('data-icinga-component');
|
$form->removeAttrib('data-icinga-component');
|
||||||
|
|
||||||
$form->setIgnoreChangeDiscarding(true);
|
$form->setIgnoreChangeDiscarding(true);
|
||||||
|
|
||||||
$badges = new FilterBadgeRenderer($this->initialFilter);
|
$badges = new FilterBadgeRenderer($this->initialFilter);
|
||||||
$html = str_replace('{{FORM}}', $form->render($view), self::$TPL);
|
$html = str_replace('{{FORM}}', $form->render($view), self::$TPL);
|
||||||
return str_replace('{{BADGES}}', $badges->render($view), $html);
|
return str_replace('{{BADGES}}', $badges->render($view), $html);
|
||||||
|
@ -34,6 +34,7 @@ use Zend_View_Abstract;
|
|||||||
use Icinga\Web\Form\Decorator\ConditionalHidden;
|
use Icinga\Web\Form\Decorator\ConditionalHidden;
|
||||||
use Zend_Form_Element_Submit;
|
use Zend_Form_Element_Submit;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sortbox widget
|
* Sortbox widget
|
||||||
*
|
*
|
||||||
@ -77,7 +78,7 @@ class SortBox implements Widget
|
|||||||
/**
|
/**
|
||||||
* A request object used for initial form population
|
* A request object used for initial form population
|
||||||
*
|
*
|
||||||
* @var Icinga\Web\Request
|
* @var \Icinga\Web\Request
|
||||||
*/
|
*/
|
||||||
private $request;
|
private $request;
|
||||||
|
|
||||||
@ -166,6 +167,7 @@ class SortBox implements Widget
|
|||||||
$form->addElement($this->createFallbackSubmitButton());
|
$form->addElement($this->createFallbackSubmitButton());
|
||||||
|
|
||||||
if ($this->request) {
|
if ($this->request) {
|
||||||
|
$form->setAction($this->request->getRequestUri());
|
||||||
$form->populate($this->request->getParams());
|
$form->populate($this->request->getParams());
|
||||||
}
|
}
|
||||||
return $form->render($view);
|
return $form->render($view);
|
||||||
|
@ -238,7 +238,7 @@ class Tab implements Widget
|
|||||||
$tagParams .= ' ' . $key . '="' . $value . '"';
|
$tagParams .= ' ' . $key . '="' . $value . '"';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$tab = '<a' . $tagParams .' href="' . $this->url->getAbsoluteUrl() . '">' . $caption . '</a>';
|
$tab = '<a' . $tagParams .' href="' . $this->url->getAbsoluteUrl() . '" data-icinga-target="self">' . $caption . '</a>';
|
||||||
} else {
|
} else {
|
||||||
$tab = $caption;
|
$tab = $caption;
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,7 @@ use Icinga\Module\Monitoring\DataView\Comment as CommentView;
|
|||||||
use Icinga\Module\Monitoring\DataView\Groupsummary as GroupsummaryView;
|
use Icinga\Module\Monitoring\DataView\Groupsummary as GroupsummaryView;
|
||||||
use Icinga\Module\Monitoring\DataView\EventHistory as EventHistoryView;
|
use Icinga\Module\Monitoring\DataView\EventHistory as EventHistoryView;
|
||||||
use Icinga\Module\Monitoring\Filter\UrlViewFilter;
|
use Icinga\Module\Monitoring\Filter\UrlViewFilter;
|
||||||
|
use Icinga\Module\Monitoring\DataView\ServiceStatus;
|
||||||
use Icinga\Filter\Filterable;
|
use Icinga\Filter\Filterable;
|
||||||
|
|
||||||
class Monitoring_ListController extends MonitoringController
|
class Monitoring_ListController extends MonitoringController
|
||||||
@ -131,7 +132,8 @@ class Monitoring_ListController extends MonitoringController
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
$query = $dataview->getQuery();
|
$query = $dataview->getQuery();
|
||||||
$this->setupFilterControl($dataview);
|
$this->setupFilterControl($dataview, 'host');
|
||||||
|
|
||||||
$this->setupSortControl(array(
|
$this->setupSortControl(array(
|
||||||
'host_last_check' => 'Last Host Check',
|
'host_last_check' => 'Last Host Check',
|
||||||
'host_severity' => 'Host Severity',
|
'host_severity' => 'Host Severity',
|
||||||
@ -152,6 +154,8 @@ class Monitoring_ListController extends MonitoringController
|
|||||||
{
|
{
|
||||||
$this->compactView = 'services-compact';
|
$this->compactView = 'services-compact';
|
||||||
$this->view->services = $this->fetchServices();
|
$this->view->services = $this->fetchServices();
|
||||||
|
|
||||||
|
$this->setupFilterControl(ServiceStatus::fromRequest($this->getRequest()), 'service');
|
||||||
$this->setupSortControl(array(
|
$this->setupSortControl(array(
|
||||||
'service_last_check' => 'Last Service Check',
|
'service_last_check' => 'Last Service Check',
|
||||||
'service_severity' => 'Severity',
|
'service_severity' => 'Severity',
|
||||||
@ -437,12 +441,12 @@ class Monitoring_ListController extends MonitoringController
|
|||||||
$this->view->sortControl->applyRequest($this->getRequest());
|
$this->view->sortControl->applyRequest($this->getRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function setupFilterControl(Filterable $dataview)
|
private function setupFilterControl(Filterable $dataview, $domain)
|
||||||
{
|
{
|
||||||
$parser = new UrlViewFilter($dataview);
|
$parser = new UrlViewFilter($dataview);
|
||||||
$this->view->filterBox = new FilterBox(
|
$this->view->filterBox = new FilterBox(
|
||||||
$parser->parseUrl(),
|
$parser->fromRequest($this->getRequest()),
|
||||||
'host',
|
$domain,
|
||||||
'monitoring'
|
'monitoring'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -55,7 +55,16 @@ class Monitoring_ShowController extends MonitoringController
|
|||||||
*/
|
*/
|
||||||
public function init()
|
public function init()
|
||||||
{
|
{
|
||||||
|
|
||||||
|
if ($this->getRequest()->getActionName() === 'host') {
|
||||||
|
$this->view->object = new Host($this->getRequest());
|
||||||
|
} elseif ($this->getRequest()->getActionName() === 'service') {
|
||||||
|
$this->view->object = new Service($this->getRequest());
|
||||||
|
|
||||||
|
} else {
|
||||||
$this->view->object = AbstractObject::fromRequest($this->getRequest());
|
$this->view->object = AbstractObject::fromRequest($this->getRequest());
|
||||||
|
}
|
||||||
|
|
||||||
$this->createTabs();
|
$this->createTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,8 +5,19 @@ $viewHelper = $this->getHelper('MonitoringState');
|
|||||||
<?= $this->tabs->render($this); ?>
|
<?= $this->tabs->render($this); ?>
|
||||||
<h1>Services Status</h1>
|
<h1>Services Status</h1>
|
||||||
<div data-icinga-component="app/mainDetailGrid">
|
<div data-icinga-component="app/mainDetailGrid">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<?= $this->filterBox->render($this); ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
<?= $this->sortControl->render($this); ?>
|
<?= $this->sortControl->render($this); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
<?= $this->paginationControl($services, null, null, array('preserve' => $this->preserve)); ?>
|
<?= $this->paginationControl($services, null, null, array('preserve' => $this->preserve)); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<table class="table table-condensed">
|
<table class="table table-condensed">
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($services as $service): ?>
|
<?php foreach ($services as $service): ?>
|
||||||
|
@ -13,6 +13,7 @@ use Icinga\Web\Controller\ActionController;
|
|||||||
*/
|
*/
|
||||||
class Controller extends ActionController
|
class Controller extends ActionController
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve services from either given parameters or request
|
* Retrieve services from either given parameters or request
|
||||||
*
|
*
|
||||||
|
@ -74,7 +74,7 @@ abstract class DataView implements Filterable
|
|||||||
|
|
||||||
$view = new static(Backend::createBackend($request->getParam('backend')), $columns);
|
$view = new static(Backend::createBackend($request->getParam('backend')), $columns);
|
||||||
$parser = new UrlViewFilter($view);
|
$parser = new UrlViewFilter($view);
|
||||||
$view->getQuery()->setFilter($parser->parseUrl());
|
$view->getQuery()->setFilter($parser->fromRequest($request));
|
||||||
|
|
||||||
$order = $request->getParam('dir');
|
$order = $request->getParam('dir');
|
||||||
if ($order !== null) {
|
if ($order !== null) {
|
||||||
@ -102,9 +102,12 @@ abstract class DataView implements Filterable
|
|||||||
public static function fromParams(array $params, array $columns = null)
|
public static function fromParams(array $params, array $columns = null)
|
||||||
{
|
{
|
||||||
$view = new static(Backend::createBackend($params['backend']), $columns);
|
$view = new static(Backend::createBackend($params['backend']), $columns);
|
||||||
|
|
||||||
foreach ($params as $key => $value) {
|
foreach ($params as $key => $value) {
|
||||||
|
if ($view->isValidFilterTarget($key)) {
|
||||||
$view->getQuery()->where($key, $value);
|
$view->getQuery()->where($key, $value);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
$order = isset($params['order']) ? $params['order'] : null;
|
$order = isset($params['order']) ? $params['order'] : null;
|
||||||
if ($order !== null) {
|
if ($order !== null) {
|
||||||
if (strtolower($order) === 'desc') {
|
if (strtolower($order) === 'desc') {
|
||||||
|
@ -28,20 +28,35 @@
|
|||||||
|
|
||||||
namespace Icinga\Module\Monitoring\Filter;
|
namespace Icinga\Module\Monitoring\Filter;
|
||||||
|
|
||||||
|
use Icinga\Application\Icinga;
|
||||||
|
use Icinga\Application\Logger;
|
||||||
use Icinga\Filter\Domain;
|
use Icinga\Filter\Domain;
|
||||||
use Icinga\Filter\FilterAttribute;
|
use Icinga\Filter\FilterAttribute;
|
||||||
use Icinga\Filter\Query\Node;
|
use Icinga\Filter\Query\Node;
|
||||||
|
use Icinga\Filter\Query\Tree;
|
||||||
use Icinga\Filter\Type\BooleanFilter;
|
use Icinga\Filter\Type\BooleanFilter;
|
||||||
use Icinga\Filter\Type\TextFilter;
|
use Icinga\Filter\Type\TextFilter;
|
||||||
use Icinga\Filter\Type\TimeRangeSpecifier;
|
use Icinga\Filter\Type\TimeRangeSpecifier;
|
||||||
|
use Icinga\Module\Monitoring\DataView\HostStatus;
|
||||||
|
use Icinga\Module\Monitoring\DataView\ServiceStatus;
|
||||||
use Icinga\Module\Monitoring\Filter\Type\StatusFilter;
|
use Icinga\Module\Monitoring\Filter\Type\StatusFilter;
|
||||||
|
use Icinga\Filter\Registry as FilterRegistry;
|
||||||
|
use Icinga\Module\Monitoring\Object\Host;
|
||||||
|
use Icinga\Web\Request;
|
||||||
|
use Zend_Controller_Request_Exception;
|
||||||
|
use Icinga\Web\Url;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory class to create filter for different monitoring objects
|
* Factory class to create filter for different monitoring objects
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
class Registry
|
class Registry implements FilterRegistry
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Return a TimeRangeSpecifier for the 'Next Check' query
|
||||||
|
*
|
||||||
|
* @return TimeRangeSpecifier
|
||||||
|
*/
|
||||||
public static function getNextCheckFilterType()
|
public static function getNextCheckFilterType()
|
||||||
{
|
{
|
||||||
$type = new TimeRangeSpecifier();
|
$type = new TimeRangeSpecifier();
|
||||||
@ -54,6 +69,11 @@ class Registry
|
|||||||
return $type;
|
return $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a TimeRangeSpecifier for the 'Last Check' query
|
||||||
|
*
|
||||||
|
* @return TimeRangeSpecifier
|
||||||
|
*/
|
||||||
public static function getLastCheckFilterType()
|
public static function getLastCheckFilterType()
|
||||||
{
|
{
|
||||||
$type = new TimeRangeSpecifier();
|
$type = new TimeRangeSpecifier();
|
||||||
@ -68,6 +88,11 @@ class Registry
|
|||||||
return $type;
|
return $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry function for the host domain
|
||||||
|
*
|
||||||
|
* @return Domain the domain to use in the filter registry
|
||||||
|
*/
|
||||||
public static function hostFilter()
|
public static function hostFilter()
|
||||||
{
|
{
|
||||||
$domain = new Domain('Host');
|
$domain = new Domain('Host');
|
||||||
@ -78,7 +103,6 @@ class Registry
|
|||||||
->setField('host_name')
|
->setField('host_name')
|
||||||
)->registerAttribute(
|
)->registerAttribute(
|
||||||
FilterAttribute::create(StatusFilter::createForHost())
|
FilterAttribute::create(StatusFilter::createForHost())
|
||||||
->setHandledAttributes('State', 'Status', 'Current Status')
|
|
||||||
->setField('host_state')
|
->setField('host_state')
|
||||||
)->registerAttribute(
|
)->registerAttribute(
|
||||||
FilterAttribute::create(new BooleanFilter(
|
FilterAttribute::create(new BooleanFilter(
|
||||||
@ -103,4 +127,104 @@ class Registry
|
|||||||
);
|
);
|
||||||
return $domain;
|
return $domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry function for the service domain
|
||||||
|
*
|
||||||
|
* @return Domain the domain to use in the filter registry
|
||||||
|
*/
|
||||||
|
public static function serviceFilter()
|
||||||
|
{
|
||||||
|
$domain = new Domain('Service');
|
||||||
|
|
||||||
|
$domain->registerAttribute(
|
||||||
|
FilterAttribute::create(new TextFilter())
|
||||||
|
->setHandledAttributes('Name', 'Servicename')
|
||||||
|
->setField('service_name')
|
||||||
|
)->registerAttribute(
|
||||||
|
FilterAttribute::create(StatusFilter::createForService())
|
||||||
|
->setField('service_state')
|
||||||
|
)->registerAttribute(
|
||||||
|
FilterAttribute::create(StatusFilter::createForHost())
|
||||||
|
->setHandledAttributes('Host')
|
||||||
|
->setField('host_state')
|
||||||
|
)->registerAttribute(
|
||||||
|
FilterAttribute::create(new BooleanFilter(
|
||||||
|
array(
|
||||||
|
'service_is_flapping' => 'Flapping',
|
||||||
|
'service_problem' => 'In Problem State',
|
||||||
|
'service_notifications_enabled' => 'Sending Notifications',
|
||||||
|
'service_active_checks_enabled' => 'Active',
|
||||||
|
'service_passive_checks_enabled' => 'Accepting Passive Checks',
|
||||||
|
'service_handled' => 'Handled',
|
||||||
|
'service_in_downtime' => 'In Downtime',
|
||||||
|
'host_in_downtime' => 'In Host Downtime'
|
||||||
|
)
|
||||||
|
))
|
||||||
|
)->registerAttribute(
|
||||||
|
FilterAttribute::create(self::getLastCheckFilterType())
|
||||||
|
->setHandledAttributes('Last Check', 'Check')
|
||||||
|
->setField('service_last_check')
|
||||||
|
)->registerAttribute(
|
||||||
|
FilterAttribute::create(self::getNextCheckFilterType())
|
||||||
|
->setHandledAttributes('Next Check')
|
||||||
|
->setField('service_next_check')
|
||||||
|
);
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the given filter to an url, using the referer as the base url and base filter
|
||||||
|
*
|
||||||
|
* @param $domain The domain to filter for
|
||||||
|
* @param Tree $filter The tree representing the fiter
|
||||||
|
*
|
||||||
|
* @return string An url
|
||||||
|
* @throws Zend_Controller_Request_Exception Called if no referer is available
|
||||||
|
*/
|
||||||
|
public static function getUrlForTarget($domain, Tree $filter)
|
||||||
|
{
|
||||||
|
if (!isset($_SERVER['HTTP_REFERER'])) {
|
||||||
|
throw new Zend_Controller_Request_Exception('You can\'t use this method without an referer');
|
||||||
|
}
|
||||||
|
$request = Icinga::app()->getFrontController()->getRequest();
|
||||||
|
switch ($domain) {
|
||||||
|
case 'host':
|
||||||
|
$view = HostStatus::fromRequest($request);
|
||||||
|
break;
|
||||||
|
case 'service':
|
||||||
|
$view = ServiceStatus::fromRequest($request);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Logger::error('Invalid filter domain requested : %s', $domain);
|
||||||
|
throw new Exception('Unknown Domain ' . $domain);
|
||||||
|
}
|
||||||
|
$urlParser = new UrlViewFilter($view);
|
||||||
|
$lastQuery = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_QUERY);
|
||||||
|
$lastPath = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_PATH);
|
||||||
|
$lastFilter = $urlParser->parseUrl($lastQuery);
|
||||||
|
$lastParameters = array();
|
||||||
|
|
||||||
|
parse_str($lastQuery, $lastParameters);
|
||||||
|
if ($lastFilter->root) {
|
||||||
|
$filter->insert($lastFilter->root);
|
||||||
|
}
|
||||||
|
$params = array();
|
||||||
|
foreach ($lastParameters as $key => $param) {
|
||||||
|
if (!$filter->hasNodeWithAttribute($key)) {
|
||||||
|
$params[$key] = $param;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = Url::fromPath($lastPath, $params);
|
||||||
|
$urlString = $baseUrl->getRelativeUrl();
|
||||||
|
if (stripos($urlString, '?') === false) {
|
||||||
|
$urlString .= '?';
|
||||||
|
} else {
|
||||||
|
$urlString .= '&';
|
||||||
|
}
|
||||||
|
$urlString .= $urlParser->fromTree($filter);
|
||||||
|
return '/' . $urlString;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -127,7 +127,6 @@ class StatusFilter extends FilterType
|
|||||||
public static function createForService()
|
public static function createForService()
|
||||||
{
|
{
|
||||||
$status = new StatusFilter();
|
$status = new StatusFilter();
|
||||||
$status->setType(self::TYPE_SERVICE);
|
|
||||||
$status->setBaseStates(
|
$status->setBaseStates(
|
||||||
array(
|
array(
|
||||||
'Ok' => 0,
|
'Ok' => 0,
|
||||||
|
@ -33,6 +33,7 @@ namespace Icinga\Module\Monitoring\Filter;
|
|||||||
use Icinga\Filter\Filterable;
|
use Icinga\Filter\Filterable;
|
||||||
use Icinga\Filter\Query\Tree;
|
use Icinga\Filter\Query\Tree;
|
||||||
use Icinga\Filter\Query\Node;
|
use Icinga\Filter\Query\Node;
|
||||||
|
use Icinga\Web\Request;
|
||||||
use Icinga\Web\Url;
|
use Icinga\Web\Url;
|
||||||
use Icinga\Application\Logger;
|
use Icinga\Application\Logger;
|
||||||
|
|
||||||
@ -114,6 +115,15 @@ class UrlViewFilter
|
|||||||
return $tree->getCopyForFilterable($this->target);
|
return $tree->getCopyForFilterable($this->target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fromRequest($request)
|
||||||
|
{
|
||||||
|
if($request->getParam('query')) {
|
||||||
|
return $this->parseUrl(urldecode($request->getParam('query')));
|
||||||
|
} else {
|
||||||
|
return $this->parseUrl(parse_url($request->getBaseUrl(), PHP_URL_QUERY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a tree node and it's subnodes to a request string
|
* Convert a tree node and it's subnodes to a request string
|
||||||
*
|
*
|
||||||
|
@ -52,6 +52,7 @@ abstract class AbstractObject
|
|||||||
$this->comments = Comment::fromRequest(
|
$this->comments = Comment::fromRequest(
|
||||||
$this->request,
|
$this->request,
|
||||||
array(
|
array(
|
||||||
|
'comment_internal_id',
|
||||||
'comment_timestamp',
|
'comment_timestamp',
|
||||||
'comment_author',
|
'comment_author',
|
||||||
'comment_data',
|
'comment_data',
|
||||||
@ -59,6 +60,7 @@ abstract class AbstractObject
|
|||||||
)
|
)
|
||||||
)->getQuery()
|
)->getQuery()
|
||||||
->where('comment_objecttype_id', 1)
|
->where('comment_objecttype_id', 1)
|
||||||
|
|
||||||
->fetchAll();
|
->fetchAll();
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@ -175,7 +177,6 @@ abstract class AbstractObject
|
|||||||
|
|
||||||
abstract public function populate();
|
abstract public function populate();
|
||||||
|
|
||||||
|
|
||||||
public static function fromRequest(Request $request)
|
public static function fromRequest(Request $request)
|
||||||
{
|
{
|
||||||
if ($request->has('service') && $request->has('host')) {
|
if ($request->has('service') && $request->has('host')) {
|
||||||
|
@ -74,7 +74,7 @@ define(['components/app/container', 'jquery'], function(Container, $) {
|
|||||||
}
|
}
|
||||||
}).done(function() {
|
}).done(function() {
|
||||||
var container = getOwnerContainer(form);
|
var container = getOwnerContainer(form);
|
||||||
container.replaceDomFromUrl(container.getContainerHref());
|
container.setUrl(container.getUrl());
|
||||||
}).error(function() {
|
}).error(function() {
|
||||||
submit.removeAttr('disabled');
|
submit.removeAttr('disabled');
|
||||||
});
|
});
|
||||||
|
@ -26,8 +26,8 @@
|
|||||||
*/
|
*/
|
||||||
// {{{ICINGA_LICENSE_HEADER}}}
|
// {{{ICINGA_LICENSE_HEADER}}}
|
||||||
|
|
||||||
define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITemplate'],
|
define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITemplate', 'icinga/util/url'],
|
||||||
function($, logger, componentLoader, URI) {
|
function($, logger, componentLoader, URI, Tpl, urlMgr) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var Icinga;
|
var Icinga;
|
||||||
@ -57,6 +57,7 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
*/
|
*/
|
||||||
var detailContainer = null;
|
var detailContainer = null;
|
||||||
|
|
||||||
|
var pendingDetailRequest = null;
|
||||||
/**
|
/**
|
||||||
* A handler for accessing icinga containers, i.e. the #icingamain, #icingadetail containers and specific 'app/container'
|
* A handler for accessing icinga containers, i.e. the #icingamain, #icingadetail containers and specific 'app/container'
|
||||||
* components.
|
* components.
|
||||||
@ -74,12 +75,6 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
*/
|
*/
|
||||||
var Container = function(target) {
|
var Container = function(target) {
|
||||||
|
|
||||||
/**
|
|
||||||
* Set to true when no history changes should be made
|
|
||||||
*
|
|
||||||
* @type {boolean} true to disable History.js calls, false to reenable them
|
|
||||||
*/
|
|
||||||
this.freezeHistory = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the container that is at the nearest location to this element, or the element itself if it is a container
|
* Return the container that is at the nearest location to this element, or the element itself if it is a container
|
||||||
@ -116,7 +111,6 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
} else {
|
} else {
|
||||||
this.containerType = CONTAINER_TYPES.GENERIC;
|
this.containerType = CONTAINER_TYPES.GENERIC;
|
||||||
}
|
}
|
||||||
this.containerDom.attr('data-icinga-href', this.getContainerHref());
|
|
||||||
|
|
||||||
if (this.containerDom.data('loadIndicator') !== true) {
|
if (this.containerDom.data('loadIndicator') !== true) {
|
||||||
this.installDefaultLoadIndicator();
|
this.installDefaultLoadIndicator();
|
||||||
@ -124,6 +118,7 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the window without the hostname
|
* Returns the window without the hostname
|
||||||
*
|
*
|
||||||
@ -133,99 +128,6 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
return window.location.pathname + window.location.search + window.location.hash;
|
return window.location.pathname + window.location.search + window.location.hash;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract and return the main container's location from the current Url
|
|
||||||
*
|
|
||||||
* This takes the window's Url and removes the detail part
|
|
||||||
*
|
|
||||||
* @returns {string} The Url of the main container
|
|
||||||
*/
|
|
||||||
var getMainContainerHrefFromUrl = function(baseUrl) {
|
|
||||||
// main has the url without the icingadetail part
|
|
||||||
var href = URI(getWindowLocationWithoutHost(baseUrl));
|
|
||||||
href.removeQuery('detail');
|
|
||||||
return href.href();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the detail container's location from the current Url
|
|
||||||
*
|
|
||||||
* This takes the detail parameter of the url and returns it or
|
|
||||||
* undefined if no location is given
|
|
||||||
*
|
|
||||||
* @returns {string|undefined} The Url of the detail container or undefined if no detail container is active
|
|
||||||
*/
|
|
||||||
var getDetailContainerHrefFromUrl = function(baseUrl) {
|
|
||||||
var location = new URI(baseUrl);
|
|
||||||
var href = URI.parseQuery(location.query()).detail;
|
|
||||||
if (!href) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// detail is a query param, so it is possible that (due to a bug or whatever) multiple
|
|
||||||
// detail fields are declared and returned as arrays
|
|
||||||
if (typeof href !== 'string') {
|
|
||||||
href = href[0];
|
|
||||||
}
|
|
||||||
// transform the detail parmameter to an Url
|
|
||||||
return URI(href).href();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the Url of this container
|
|
||||||
*
|
|
||||||
* This is mostly determined by the Url of the window, but for generic containers we have to rely on the
|
|
||||||
* "data-icinga-href" attribute of the container (which is also available for main and detail, but less
|
|
||||||
* reliable during history changes)
|
|
||||||
*
|
|
||||||
* @returns {String|undefined} The Url of the container or undefined if the container has no Url set
|
|
||||||
*/
|
|
||||||
this.getContainerHref = function(baseUrl) {
|
|
||||||
baseUrl = baseUrl || getWindowLocationWithoutHost();
|
|
||||||
switch (this.containerType) {
|
|
||||||
case CONTAINER_TYPES.MAIN:
|
|
||||||
return getMainContainerHrefFromUrl(baseUrl);
|
|
||||||
case CONTAINER_TYPES.DETAIL:
|
|
||||||
return getDetailContainerHrefFromUrl(baseUrl);
|
|
||||||
case CONTAINER_TYPES.GENERIC:
|
|
||||||
if (this.containerDom.attr('data-icinga-href')) {
|
|
||||||
return URI(this.containerDom.attr('data-icinga-href'));
|
|
||||||
} else {
|
|
||||||
return URI(baseUrl).href();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a href with representing the current view, but url as the main container
|
|
||||||
*
|
|
||||||
* @param {URI} url The main Url to use as an URI.js object
|
|
||||||
*
|
|
||||||
* @returns {URI} The modified URI.js containing the new main and the current detail link
|
|
||||||
*/
|
|
||||||
var setMainContainerHref = function(url, baseUrl) {
|
|
||||||
var detail = getDetailContainerHrefFromUrl(baseUrl);
|
|
||||||
if (detail) {
|
|
||||||
url.addQuery('detail', detail);
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a complete Href string representing the current detail href and the provided main Url
|
|
||||||
*
|
|
||||||
* @param {URI} url The detail Url to use as an URI.js object
|
|
||||||
*
|
|
||||||
* @returns {URI} The modified URI.js containing the new detail and the current main link
|
|
||||||
*/
|
|
||||||
var setDetailContainerHref = function(url, baseUrl) {
|
|
||||||
var location = new URI(baseUrl);
|
|
||||||
location.removeQuery('detail');
|
|
||||||
if (typeof url !== 'undefined') { // no detail Url given
|
|
||||||
location.addQuery('detail', url);
|
|
||||||
}
|
|
||||||
return location;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create default load mask
|
* Create default load mask
|
||||||
*
|
*
|
||||||
@ -234,7 +136,6 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
var createDefaultLoadIndicator = function() {
|
var createDefaultLoadIndicator = function() {
|
||||||
|
|
||||||
this.showDetail();
|
this.showDetail();
|
||||||
|
|
||||||
if (this.containerDom.find('div.load-indicator').length === 0) {
|
if (this.containerDom.find('div.load-indicator').length === 0) {
|
||||||
var content = '<div class="load-indicator">' +
|
var content = '<div class="load-indicator">' +
|
||||||
'<div class="mask"></div>' +
|
'<div class="mask"></div>' +
|
||||||
@ -253,46 +154,6 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
this.containerDom.find('div.load-indicator').remove();
|
this.containerDom.find('div.load-indicator').remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the Url of this container and let the Url reflect the new changes, if required
|
|
||||||
*
|
|
||||||
* This updates the window Url and the data-icinga-href attribute of the container. The latter one is required
|
|
||||||
* to see which url is the last one the container displayed (e.g. after History changes, the url has changed
|
|
||||||
* but the containers data-icinga-href still points to the containers element).
|
|
||||||
*
|
|
||||||
* @param {String|URI} url An Url string or a URI.js object representing the new Url for this container
|
|
||||||
*
|
|
||||||
* @return {String} url The new Url of the application (main and detail)
|
|
||||||
*/
|
|
||||||
this.updateContainerHref = function(url, baseUrl) {
|
|
||||||
baseUrl = baseUrl || getWindowLocationWithoutHost();
|
|
||||||
if (typeof url === "string") {
|
|
||||||
url = URI(url);
|
|
||||||
}
|
|
||||||
var containerUrl, windowUrl;
|
|
||||||
switch (this.containerType) {
|
|
||||||
case CONTAINER_TYPES.MAIN:
|
|
||||||
windowUrl = setMainContainerHref(url, baseUrl);
|
|
||||||
containerUrl = windowUrl.clone().removeQuery('detail');
|
|
||||||
break;
|
|
||||||
case CONTAINER_TYPES.DETAIL:
|
|
||||||
windowUrl = setDetailContainerHref(url, baseUrl);
|
|
||||||
containerUrl = url;
|
|
||||||
break;
|
|
||||||
case CONTAINER_TYPES.GENERIC:
|
|
||||||
containerUrl = url;
|
|
||||||
windowUrl = baseUrl;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerUrl) {
|
|
||||||
this.containerDom.attr('data-icinga-href', containerUrl);
|
|
||||||
} else {
|
|
||||||
this.containerDom.removeAttr('data-icinga-href');
|
|
||||||
}
|
|
||||||
|
|
||||||
return windowUrl.href();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the provided url, stop all pending requests for this container and call replaceDom for the returned html
|
* Load the provided url, stop all pending requests for this container and call replaceDom for the returned html
|
||||||
@ -301,9 +162,59 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
*
|
*
|
||||||
* @param {String, URI} url The Url to load or and URI.js object encapsulating it
|
* @param {String, URI} url The Url to load or and URI.js object encapsulating it
|
||||||
*/
|
*/
|
||||||
this.replaceDomFromUrl = function(url) {
|
this.updateFromUrl = function(url) {
|
||||||
|
|
||||||
|
if (this.containerType === CONTAINER_TYPES.DETAIL) {
|
||||||
|
urlMgr.setDetailUrl(url);
|
||||||
|
} else {
|
||||||
|
urlMgr.setMainUrl(url);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.replaceDomAsync = function(url) {
|
||||||
|
if (url === '') {
|
||||||
|
this.containerDom.empty();
|
||||||
|
this.hideDetail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingDetailRequest) {
|
||||||
|
pendingDetailRequest.abort();
|
||||||
|
}
|
||||||
this.containerDom.trigger('showLoadIndicator');
|
this.containerDom.trigger('showLoadIndicator');
|
||||||
Icinga.replaceBodyFromUrl(this.updateContainerHref(url));
|
pendingDetailRequest = $.ajax({
|
||||||
|
'url' : url,
|
||||||
|
'data' : {
|
||||||
|
'render' : 'detail'
|
||||||
|
}
|
||||||
|
}).done(
|
||||||
|
(function(response) {
|
||||||
|
this.replaceDom($(response));
|
||||||
|
}).bind(this)
|
||||||
|
).fail(
|
||||||
|
(function(response, reason) {
|
||||||
|
var errorReason;
|
||||||
|
if (response.statusCode.toString()[0] === '4') {
|
||||||
|
errorReason = 'The Requested View Couldn\'t Be Found<br/>';
|
||||||
|
} else {
|
||||||
|
errorReason = 'An Internal Error Occured';
|
||||||
|
}
|
||||||
|
this.replaceDom(
|
||||||
|
$('<div class="alert alert-danger">').text(errorReason)
|
||||||
|
);
|
||||||
|
}).bind(this)
|
||||||
|
).always((function() {
|
||||||
|
this.containerDom.trigger('hideLoadIndicator');
|
||||||
|
}).bind(this));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getUrl = function() {
|
||||||
|
if (this.containerType === CONTAINER_TYPES.DETAIL) {
|
||||||
|
return urlMgr.detailUrl;
|
||||||
|
} else {
|
||||||
|
return urlMgr.mainUrl;
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -377,6 +288,51 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
this.containerDom.off('hideLoadIndicator');
|
this.containerDom.off('hideLoadIndicator');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.onLinkClick = function(ev, target) {
|
||||||
|
if ($.trim($(target).attr('href')) === '#') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var url = URI($(target).attr('href'));
|
||||||
|
var explicitTarget = $(target).attr('data-icinga-target');
|
||||||
|
|
||||||
|
var isHash = ('#' + url.fragment() === url.href());
|
||||||
|
if (isHash) {
|
||||||
|
|
||||||
|
explicitTarget = this.containerType === CONTAINER_TYPES.MAIN ? 'main' : 'detail';
|
||||||
|
}
|
||||||
|
if (explicitTarget) {
|
||||||
|
|
||||||
|
urlMgr[{
|
||||||
|
'main' : 'setMainUrl',
|
||||||
|
'detail' : 'setDetailUrl',
|
||||||
|
'self' : 'setUrl'
|
||||||
|
}[explicitTarget]](url.href());
|
||||||
|
|
||||||
|
} else if (this.containerType === CONTAINER_TYPES.MAIN) {
|
||||||
|
urlMgr.setDetailUrl(url.href());
|
||||||
|
} else {
|
||||||
|
urlMgr.setMainUrl(url.href());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
return false;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setUrl = function(url) {
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
url = URI(url);
|
||||||
|
}
|
||||||
|
console.log(url);
|
||||||
|
if (this.containerType === CONTAINER_TYPES.MAIN) {
|
||||||
|
urlMgr.setMainUrl(url.href());
|
||||||
|
} else {
|
||||||
|
urlMgr.setDetailUrl(url.href());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.construct(target);
|
this.construct(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -389,12 +345,11 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
* when the link should be catched and processed internally
|
* when the link should be catched and processed internally
|
||||||
*/
|
*/
|
||||||
Container.isExternalLink = function(link) {
|
Container.isExternalLink = function(link) {
|
||||||
if (link[0] === '#') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (/^\/\//).test(URI(link).relativeTo(window.location.href).href());
|
return (/^\/\//).test(URI(link).relativeTo(window.location.href).href());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the page's detail container (which is always there)
|
* Return the page's detail container (which is always there)
|
||||||
*
|
*
|
||||||
@ -402,7 +357,7 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
*/
|
*/
|
||||||
Container.getDetailContainer = function() {
|
Container.getDetailContainer = function() {
|
||||||
detailContainer = detailContainer || new Container('#icingadetail');
|
detailContainer = detailContainer || new Container('#icingadetail');
|
||||||
if(!jQuery.contains(document.body, detailContainer)) {
|
if(!jQuery.contains(document.body, mainContainer)) {
|
||||||
detailContainer = new Container('#icingadetail');
|
detailContainer = new Container('#icingadetail');
|
||||||
}
|
}
|
||||||
return detailContainer;
|
return detailContainer;
|
||||||
@ -450,6 +405,7 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
* Available as a static method on the Container object or as an instance method
|
* Available as a static method on the Container object or as an instance method
|
||||||
*/
|
*/
|
||||||
Container.prototype.hideDetail = Container.hideDetail = function() {
|
Container.prototype.hideDetail = Container.hideDetail = function() {
|
||||||
|
urlMgr.setDetailUrl('');
|
||||||
var mainDom = Container.getMainContainer().containerDom,
|
var mainDom = Container.getMainContainer().containerDom,
|
||||||
detailDom = Container.getDetailContainer().containerDom;
|
detailDom = Container.getDetailContainer().containerDom;
|
||||||
|
|
||||||
@ -464,47 +420,7 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
mainDom.addClass('col-sm-12');
|
mainDom.addClass('col-sm-12');
|
||||||
detailDom.addClass('hidden-sm');
|
detailDom.addClass('hidden-sm');
|
||||||
detailDom.removeAttr('data-icinga-href');
|
detailDom.removeAttr('data-icinga-href');
|
||||||
if (typeof this.freezeHistory === 'undefined' || !this.freezeHistory) {
|
|
||||||
History.replaceState(
|
|
||||||
{},
|
|
||||||
document.title,
|
|
||||||
URI(window.location.href).removeQuery('detail').href()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if (Modernizr.history) {
|
|
||||||
/**
|
|
||||||
* Register the click behaviour of the main container, which means that every link, if not catched in a
|
|
||||||
* more specific handler, causes an update of the main container if it's not external or a browser behaviour link
|
|
||||||
* (those starting with '#').
|
|
||||||
*/
|
|
||||||
$('body').on('click', '#icingamain, #icingadetail', function(ev) {
|
|
||||||
|
|
||||||
var targetEl = ev.target || ev.toElement || ev.relatedTarget;
|
|
||||||
if (targetEl.tagName.toLowerCase() !== 'a') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Container.isExternalLink($(targetEl).attr('href'))) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
if ($(targetEl).attr('data-icinga-target') === 'detail') {
|
|
||||||
Icinga.replaceBodyFromUrl(
|
|
||||||
detailContainer.updateContainerHref(URI($(targetEl).attr('href')).href())
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Icinga.replaceBodyFromUrl(
|
|
||||||
mainContainer.updateContainerHref(URI($(targetEl).attr('href')).href())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injects the icinga object into the Container class
|
* Injects the icinga object into the Container class
|
||||||
*
|
*
|
||||||
@ -516,5 +432,28 @@ define(['jquery', 'logging', 'icinga/componentLoader', 'URIjs/URI', 'URIjs/URITe
|
|||||||
Icinga = icingaObj;
|
Icinga = icingaObj;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$('body').on('click', '*[data-icinga-component="app/container"], #icingamain, #icingadetail', function(ev) {
|
||||||
|
var targetEl = ev.target || ev.toElement || ev.relatedTarget;
|
||||||
|
|
||||||
|
if (targetEl.tagName.toLowerCase() !== 'a') {
|
||||||
|
targetEl = $(targetEl).parents('a')[0];
|
||||||
|
if (!targetEl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (new Container(targetEl)).onLinkClick(ev, targetEl);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
$(window).on('hashchange', (function() {
|
||||||
|
urlMgr.syncWithUrl();
|
||||||
|
Container.getDetailContainer().replaceDomAsync(urlMgr.detailUrl);
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
if (urlMgr.detailUrl) {
|
||||||
|
Container.getDetailContainer().replaceDomAsync(urlMgr.detailUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return Container;
|
return Container;
|
||||||
});
|
});
|
||||||
|
@ -25,8 +25,8 @@
|
|||||||
* @author Icinga Development Team <info@icinga.org>
|
* @author Icinga Development Team <info@icinga.org>
|
||||||
*/
|
*/
|
||||||
// {{{ICINGA_LICENSE_HEADER}}}
|
// {{{ICINGA_LICENSE_HEADER}}}
|
||||||
define(['components/app/container', 'jquery', 'logging', 'URIjs/URI', 'URIjs/URITemplate'],
|
define(['components/app/container', 'jquery', 'logging', 'URIjs/URI', 'URIjs/URITemplate', 'icinga/util/url'],
|
||||||
function(Container, $, logger, URI) {
|
function(Container, $, logger, URI, tpl, urlMgr) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,7 +141,7 @@ function(Container, $, logger, URI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Container.getDetailContainer().replaceDomFromUrl($('a', this).attr('href'));
|
urlMgr.setDetailUrl($('a', this).attr('href'));
|
||||||
if (!ev.ctrlKey && !ev.metaKey) {
|
if (!ev.ctrlKey && !ev.metaKey) {
|
||||||
$('tr', $(this).parent()).removeClass('active');
|
$('tr', $(this).parent()).removeClass('active');
|
||||||
}
|
}
|
||||||
@ -160,24 +160,31 @@ function(Container, $, logger, URI) {
|
|||||||
controlForms.on('submit', function(evt) {
|
controlForms.on('submit', function(evt) {
|
||||||
var container = (new Container(this));
|
var container = (new Container(this));
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
var url = URI(container.getContainerHref());
|
var url = container.getUrl();
|
||||||
url.search(URI.parseQuery(form.serialize()));
|
|
||||||
container.replaceDomFromUrl(url.href());
|
if (url.indexOf('?') >= 0) {
|
||||||
|
url += '&';
|
||||||
|
} else {
|
||||||
|
url += '?';
|
||||||
|
}
|
||||||
|
url += form.serialize();
|
||||||
|
container.setUrl(url);
|
||||||
|
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
});
|
});
|
||||||
$('.pagination li a', contentNode.parent()).on('click', function(ev) {
|
$('.pagination li a, a.filter-badge', contentNode.parent()).on('click', function(ev) {
|
||||||
|
|
||||||
var container = (new Container(this));
|
var container = (new Container(this));
|
||||||
logger.debug("Pagination clicked in " + container.containerType);
|
|
||||||
// Detail will be removed when main pagination changes
|
// Detail will be removed when main pagination changes
|
||||||
if (container.containerType === 'icingamain') {
|
if (container.containerType === 'icingamain') {
|
||||||
Icinga.replaceBodyFromUrl(URI($(this).attr('href')).removeQuery('detail'));
|
urlMgr.setMainUrl(URI($(this).attr('href')));
|
||||||
|
urlMgr.setDetailUrl('');
|
||||||
} else {
|
} else {
|
||||||
container.replaceDomFromUrl($(this).attr('href'));
|
urlMgr.setDetailUrl(URI($(this).attr('href')));
|
||||||
}
|
}
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@ -187,7 +194,7 @@ function(Container, $, logger, URI) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
var getSelectedRows = function() {
|
var getSelectedRows = function() {
|
||||||
return $('a[href="' + Container.getDetailContainer().getContainerHref() + '"]', determineContentTable()).
|
return $('a[href="' + urlMgr.getDetailUrl() + '"]', determineContentTable()).
|
||||||
parentsUntil('table', 'tr');
|
parentsUntil('table', 'tr');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -214,9 +221,11 @@ function(Container, $, logger, URI) {
|
|||||||
this.container.removeDefaultLoadIndicator();
|
this.container.removeDefaultLoadIndicator();
|
||||||
controlForms = determineControlForms();
|
controlForms = determineControlForms();
|
||||||
contentNode = determineContentTable();
|
contentNode = determineContentTable();
|
||||||
|
this.syncSelectionWithDetail();
|
||||||
this.registerControls();
|
this.registerControls();
|
||||||
this.registerTableLinks();
|
this.registerTableLinks();
|
||||||
this.registerHistoryChanges();
|
this.registerHistoryChanges();
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.construct(gridDomNode);
|
this.construct(gridDomNode);
|
||||||
|
@ -53,8 +53,7 @@ define(['jquery', 'logging', 'URIjs/URI', 'components/app/container'], function(
|
|||||||
* Request new proposals for the given input box
|
* Request new proposals for the given input box
|
||||||
*/
|
*/
|
||||||
this.getProposal = function() {
|
this.getProposal = function() {
|
||||||
var text = this.inputDom.val().trim();
|
var text = $.trim(this.inputDom.val());
|
||||||
|
|
||||||
|
|
||||||
if (this.pendingRequest) {
|
if (this.pendingRequest) {
|
||||||
this.pendingRequest.abort();
|
this.pendingRequest.abort();
|
||||||
@ -131,7 +130,8 @@ define(['jquery', 'logging', 'URIjs/URI', 'components/app/container'], function(
|
|||||||
* @param {Object} response The jquery response object inheritn XHttpResponse Attributes
|
* @param {Object} response The jquery response object inheritn XHttpResponse Attributes
|
||||||
*/
|
*/
|
||||||
this.showProposals = function(response) {
|
this.showProposals = function(response) {
|
||||||
if (response.proposals.length === 0) {
|
|
||||||
|
if (!response || !response.proposals || response.proposals.length === 0) {
|
||||||
this.inputDom.popover('destroy');
|
this.inputDom.popover('destroy');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -167,10 +167,12 @@ define(['jquery', 'logging', 'URIjs/URI', 'components/app/container'], function(
|
|||||||
var query = $.trim(this.inputDom.val());
|
var query = $.trim(this.inputDom.val());
|
||||||
this.pendingRequest = $.ajax(this.getRequestParams(query))
|
this.pendingRequest = $.ajax(this.getRequestParams(query))
|
||||||
.done((function(response) {
|
.done((function(response) {
|
||||||
var container = new Container($(this.inputDom));
|
var domContainer = new Container(this.inputDom);
|
||||||
var url = container.getContainerHref();
|
var url = response.urlParam;
|
||||||
url += ( url.indexOf('?') === -1 ? '?' : '&' ) + response.urlParam;
|
|
||||||
container.replaceDomFromUrl(url);
|
if (url) {
|
||||||
|
domContainer.setUrl(url);
|
||||||
|
}
|
||||||
}).bind(this));
|
}).bind(this));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -31,90 +31,94 @@ define([
|
|||||||
'logging',
|
'logging',
|
||||||
'icinga/componentLoader',
|
'icinga/componentLoader',
|
||||||
'components/app/container',
|
'components/app/container',
|
||||||
'URIjs/URI'
|
'URIjs/URI',
|
||||||
], function ($, log, components, Container, URI) {
|
'icinga/util/url'
|
||||||
|
], function ($, log, components, Container, URI, urlMgr) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icinga prototype
|
* Icinga prototype
|
||||||
*/
|
*/
|
||||||
var Icinga = function() {
|
var Icinga = function() {
|
||||||
|
var pendingRequest = null;
|
||||||
|
|
||||||
var ignoreHistoryChanges = false;
|
/**
|
||||||
|
* Initia
|
||||||
|
*/
|
||||||
var initialize = function () {
|
var initialize = function () {
|
||||||
components.load();
|
components.load();
|
||||||
ignoreHistoryChanges = true;
|
|
||||||
registerGenericHistoryHandler();
|
|
||||||
ignoreHistoryChanges = false;
|
|
||||||
log.debug("Initialization finished");
|
log.debug("Initialization finished");
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register handler for handling the history state generically
|
* Globally open the given url and reload the main/detail box to represent it
|
||||||
*
|
*
|
||||||
|
* @param url The url to load
|
||||||
*/
|
*/
|
||||||
var registerGenericHistoryHandler = function() {
|
this.openUrl = function(url) {
|
||||||
var lastUrl = URI(window.location.href);
|
if (pendingRequest) {
|
||||||
History.Adapter.bind(window, 'popstate', function() {
|
pendingRequest.abort();
|
||||||
if (ignoreHistoryChanges) {
|
}
|
||||||
|
pendingRequest = $.ajax({
|
||||||
|
"url": url
|
||||||
|
}).done(function(response) {
|
||||||
|
var dom = $(response);
|
||||||
|
var detailDom = null;
|
||||||
|
if (urlMgr.detailUrl) {
|
||||||
|
detailDom = $('#icingadetail');
|
||||||
|
}
|
||||||
|
$(document.body).empty().append(dom);
|
||||||
|
if (detailDom && detailDom.length) {
|
||||||
|
$('#icingadetail').replaceWith(detailDom);
|
||||||
|
Container.showDetail();
|
||||||
|
}
|
||||||
|
components.load();
|
||||||
|
Container.getMainContainer();
|
||||||
|
}).fail(function(response, reason) {
|
||||||
|
if (reason === 'abort') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log.error("Request failed: ", response.message);
|
||||||
gotoUrl(History.getState().url);
|
|
||||||
lastUrl = URI(window.location.href);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
var gotoUrl = function(href) {
|
|
||||||
if (typeof document.body.pending !== 'undefined') {
|
|
||||||
document.body.pending.abort();
|
|
||||||
}
|
|
||||||
if (typeof href === 'string') {
|
|
||||||
href = URI(href);
|
|
||||||
}
|
|
||||||
document.body.pending = $.ajax({
|
|
||||||
url: href.href()
|
|
||||||
}).done(function(domNodes) {
|
|
||||||
$('body').empty().append(jQuery.parseHTML(domNodes));
|
|
||||||
ignoreHistoryChanges = true;
|
|
||||||
History.pushState({}, document.title, href.href());
|
|
||||||
ignoreHistoryChanges = false;
|
|
||||||
components.load();
|
|
||||||
}).error(function(xhr, textStatus, errorThrown) {
|
|
||||||
if (xhr.responseText) {
|
|
||||||
$('body').empty().append(jQuery.parseHTML(xhr.responseText));
|
|
||||||
} else if (textStatus !== 'abort') {
|
|
||||||
logging.emergency('Could not load URL', xhr.href, textStatus, errorThrown);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Modernizr.history) {
|
if (Modernizr.history) {
|
||||||
$(document.body).on('click', '#icinganavigation', function(ev) {
|
/**
|
||||||
var targetEl = ev.target || ev.toElement || ev.relatedTarget;
|
* Event handler that will be called when the url change
|
||||||
if (targetEl.tagName.toLowerCase() !== 'a') {
|
*/
|
||||||
return true;
|
urlMgr.syncWithUrl();
|
||||||
|
var lastMain = urlMgr.mainUrl;
|
||||||
|
$(window).on('pushstate', (function() {
|
||||||
|
urlMgr.syncWithUrl();
|
||||||
|
if (urlMgr.mainUrl !== lastMain) {
|
||||||
|
this.openUrl(urlMgr.getUrl());
|
||||||
|
lastMain = urlMgr.mainUrl;
|
||||||
|
}
|
||||||
|
// If an anchor is set, scroll to it's position
|
||||||
|
if ($('#' + urlMgr.anchor).length) {
|
||||||
|
$(document.body).scrollTo($('#' + urlMgr.anchor));
|
||||||
|
}
|
||||||
|
}).bind(this));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for browser back/forward events
|
||||||
|
*/
|
||||||
|
$(window).on('popstate', (function() {
|
||||||
|
var lastMain = urlMgr.mainUrl;
|
||||||
|
urlMgr.syncWithUrl();
|
||||||
|
if (urlMgr.mainUrl !== lastMain) {
|
||||||
|
this.openUrl(urlMgr.getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
var href = $(targetEl).attr('href');
|
}).bind(this));
|
||||||
if (Container.isExternalLink(href)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
gotoUrl(href);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(initialize.bind(this));
|
$(document).ready(initialize.bind(this));
|
||||||
Container.setIcinga(this);
|
Container.setIcinga(this);
|
||||||
|
|
||||||
this.components = components;
|
this.components = components;
|
||||||
this.replaceBodyFromUrl = gotoUrl;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
194
public/js/icinga/util/url.js
Normal file
194
public/js/icinga/util/url.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
// {{{ICINGA_LICENSE_HEADER}}}
|
||||||
|
/**
|
||||||
|
* This file is part of Icinga 2 Web.
|
||||||
|
*
|
||||||
|
* Icinga 2 Web - Head for multiple monitoring backends.
|
||||||
|
* Copyright (C) 2013 Icinga Development Team
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 2
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
*
|
||||||
|
* @copyright 2013 Icinga Development Team <info@icinga.org>
|
||||||
|
* @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
|
||||||
|
* @author Icinga Development Team <info@icinga.org>
|
||||||
|
*/
|
||||||
|
// {{{ICINGA_LICENSE_HEADER}}}
|
||||||
|
|
||||||
|
define(['jquery', 'URIjs/URI'], function($, URI, Container) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var currentUrl = URI(window.location.href);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for Url handling
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
var URLMgr = function() {
|
||||||
|
/**
|
||||||
|
* The current url of the main part
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.mainUrl = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current main anchor
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.anchor = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current detail url
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.detailUrl = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current anchor of the detail url
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.detailAnchor = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the anchor of the main url part from the given url
|
||||||
|
*
|
||||||
|
* @param {String|URI} url An URL object to extract the information from
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
this.getMainAnchor = function(url) {
|
||||||
|
url = url || URI(window.location.href);
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
url = URI(url);
|
||||||
|
}
|
||||||
|
var fragment = url.fragment();
|
||||||
|
if (fragment.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var parts = fragment.split('!');
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return parts[0];
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the detail url a the given url. Returns a [URL, ANCHOR] Tupel
|
||||||
|
*
|
||||||
|
* @param String url An optional url to parse (otherwise window.location.href is used)
|
||||||
|
* @returns {Array} A [{String} Url, {String} anchor] tupel
|
||||||
|
*/
|
||||||
|
this.getDetailUrl = function(url) {
|
||||||
|
url = url || URI(window.location.href);
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
url = URI(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragment = url.fragment();
|
||||||
|
if (fragment.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var parts = fragment.split('!', 2);
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
var result = /detail=(.*)$/.exec(parts[1]);
|
||||||
|
if (!result || result.length < 2) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return result[1].replace('%23', '#').split('#');
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite the detail Url and update the hash
|
||||||
|
*
|
||||||
|
* @param String url The url to use for the detail part
|
||||||
|
*/
|
||||||
|
this.setDetailUrl = function(url) {
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
url = URI(url);
|
||||||
|
}
|
||||||
|
if( !url.fragment() || url.href() !== '#' + url.fragment()) {
|
||||||
|
this.detailUrl = url.clone().fragment('').href();
|
||||||
|
}
|
||||||
|
this.detailAnchor = this.getMainAnchor(url);
|
||||||
|
|
||||||
|
window.location.hash = this.getUrlHash();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the hash of the current detail url and anchor i
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
this.getUrlHash = function() {
|
||||||
|
var anchor = '#' + this.anchor +
|
||||||
|
'!' + ($.trim(this.detailUrl) ? 'detail=' : '') + this.detailUrl +
|
||||||
|
(this.detailAnchor ? '%23' : '') + this.detailAnchor;
|
||||||
|
anchor = $.trim(anchor);
|
||||||
|
if (anchor === '#!' || anchor === '#') {
|
||||||
|
anchor = '';
|
||||||
|
}
|
||||||
|
return anchor;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the main url to be used
|
||||||
|
*
|
||||||
|
* This triggers the pushstate event or causes a page reload if the history api is
|
||||||
|
* not available
|
||||||
|
*
|
||||||
|
* @param url
|
||||||
|
*/
|
||||||
|
this.setMainUrl = function(url) {
|
||||||
|
this.anchor = this.getMainAnchor(url);
|
||||||
|
this.mainUrl = URI(url).clone().fragment('').href();
|
||||||
|
if (!Modernizr.history) {
|
||||||
|
window.location.href = this.mainUrl + this.getUrlHash();
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, document.title, this.mainUrl + this.getUrlHash());
|
||||||
|
$(window).trigger('pushstate');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the href (main path + hash)
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
this.getUrl = function() {
|
||||||
|
return this.mainUrl + this.getUrlHash();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the current url and sync the internal state of this url manager with it
|
||||||
|
*/
|
||||||
|
this.syncWithUrl = function() {
|
||||||
|
this.mainUrl = URI(window.location.href).clone().fragment('').href();
|
||||||
|
this.anchor = this.getMainAnchor();
|
||||||
|
var urlAnchorTupel = this.getDetailUrl();
|
||||||
|
this.detailUrl = urlAnchorTupel[0] || '';
|
||||||
|
this.detailAnchor = urlAnchorTupel[1] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
this.syncWithUrl();
|
||||||
|
};
|
||||||
|
var urlMgr = new URLMgr();
|
||||||
|
|
||||||
|
return urlMgr;
|
||||||
|
});
|
@ -3,9 +3,8 @@ requirejs.config({
|
|||||||
'urlArgs': "bust=" + (new Date()).getTime(),
|
'urlArgs': "bust=" + (new Date()).getTime(),
|
||||||
'paths': {
|
'paths': {
|
||||||
'jquery': 'vendor/jquery-1.8.3',
|
'jquery': 'vendor/jquery-1.8.3',
|
||||||
'jqueryPlugins': 'vendor/jqueryPlugins/',
|
'jquery_scrollto': 'vendor/jquery.scrollto',
|
||||||
'bootstrap': 'vendor/bootstrap/bootstrap.min',
|
'bootstrap': 'vendor/bootstrap/bootstrap.min',
|
||||||
'history': 'vendor/history',
|
|
||||||
'logging': 'icinga/util/logging',
|
'logging': 'icinga/util/logging',
|
||||||
'URIjs': 'vendor/uri',
|
'URIjs': 'vendor/uri',
|
||||||
'datetimepicker': 'vendor/bootstrap/datetimepicker.min'
|
'datetimepicker': 'vendor/bootstrap/datetimepicker.min'
|
||||||
@ -14,21 +13,23 @@ requirejs.config({
|
|||||||
'datetimepicker': {
|
'datetimepicker': {
|
||||||
'exports': 'datetimepicker'
|
'exports': 'datetimepicker'
|
||||||
},
|
},
|
||||||
|
'jquery_scrollto': {
|
||||||
|
exports: 'jquery_scrollto'
|
||||||
|
},
|
||||||
'jquery' : {
|
'jquery' : {
|
||||||
exports: 'jquery'
|
exports: 'jquery'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
define(['jquery', 'history'], function ($) {
|
define(['jquery'], function ($, history) {
|
||||||
|
|
||||||
requirejs(['bootstrap'], function() {
|
|
||||||
requirejs(['datetimepicker']);
|
|
||||||
});
|
|
||||||
|
|
||||||
requirejs(['icinga/icinga'], function (Icinga) {
|
|
||||||
window.$ = $;
|
window.$ = $;
|
||||||
window.jQuery = $;
|
window.jQuery = $;
|
||||||
|
|
||||||
|
requirejs(['bootstrap','jquery_scrollto'], function() {
|
||||||
|
requirejs(['datetimepicker']);
|
||||||
|
});
|
||||||
|
requirejs(['icinga/icinga'], function (Icinga) {
|
||||||
window.Icinga = Icinga;
|
window.Icinga = Icinga;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
2
public/js/vendor/history.js
vendored
2
public/js/vendor/history.js
vendored
File diff suppressed because one or more lines are too long
8
public/js/vendor/jquery.scrollto.js
vendored
Normal file
8
public/js/vendor/jquery.scrollto.js
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2007-2013 Ariel Flesler - aflesler<a>gmail<d>com | http://flesler.blogspot.com
|
||||||
|
* Dual licensed under MIT and GPL.
|
||||||
|
* @author Ariel Flesler
|
||||||
|
* @version 1.4.6
|
||||||
|
*/
|
||||||
|
;(function($){var h=$.scrollTo=function(a,b,c){$(window).scrollTo(a,b,c)};h.defaults={axis:'xy',duration:parseFloat($.fn.jquery)>=1.3?0:1,limit:true};h.window=function(a){return $(window)._scrollable()};$.fn._scrollable=function(){return this.map(function(){var a=this,isWin=!a.nodeName||$.inArray(a.nodeName.toLowerCase(),['iframe','#document','html','body'])!=-1;if(!isWin)return a;var b=(a.contentWindow||a).document||a.ownerDocument||a;return/webkit/i.test(navigator.userAgent)||b.compatMode=='BackCompat'?b.body:b.documentElement})};$.fn.scrollTo=function(e,f,g){if(typeof f=='object'){g=f;f=0}if(typeof g=='function')g={onAfter:g};if(e=='max')e=9e9;g=$.extend({},h.defaults,g);f=f||g.duration;g.queue=g.queue&&g.axis.length>1;if(g.queue)f/=2;g.offset=both(g.offset);g.over=both(g.over);return this._scrollable().each(function(){if(e==null)return;var d=this,$elem=$(d),targ=e,toff,attr={},win=$elem.is('html,body');switch(typeof targ){case'number':case'string':if(/^([+-]=?)?\d+(\.\d+)?(px|%)?$/.test(targ)){targ=both(targ);break}targ=$(targ,this);if(!targ.length)return;case'object':if(targ.is||targ.style)toff=(targ=$(targ)).offset()}$.each(g.axis.split(''),function(i,a){var b=a=='x'?'Left':'Top',pos=b.toLowerCase(),key='scroll'+b,old=d[key],max=h.max(d,a);if(toff){attr[key]=toff[pos]+(win?0:old-$elem.offset()[pos]);if(g.margin){attr[key]-=parseInt(targ.css('margin'+b))||0;attr[key]-=parseInt(targ.css('border'+b+'Width'))||0}attr[key]+=g.offset[pos]||0;if(g.over[pos])attr[key]+=targ[a=='x'?'width':'height']()*g.over[pos]}else{var c=targ[pos];attr[key]=c.slice&&c.slice(-1)=='%'?parseFloat(c)/100*max:c}if(g.limit&&/^\d+$/.test(attr[key]))attr[key]=attr[key]<=0?0:Math.min(attr[key],max);if(!i&&g.queue){if(old!=attr[key])animate(g.onAfterFirst);delete attr[key]}});animate(g.onAfter);function animate(a){$elem.animate(attr,f,g.easing,a&&function(){a.call(this,targ,g)})}}).end()};h.max=function(a,b){var c=b=='x'?'Width':'Height',scroll='scroll'+c;if(!$(a).is('html,body'))return a[scroll]-$(a)[c.toLowerCase()]();var d='client'+c,html=a.ownerDocument.documentElement,body=a.ownerDocument.body;return Math.max(html[scroll],body[scroll])-Math.min(html[d],body[d])};function both(a){return typeof a=='object'?a:{top:a,left:a}}})(jQuery);
|
||||||
|
;(function($){var h=location.href.replace(/#.*/,'');var i=$.localScroll=function(a){$('body').localScroll(a)};i.defaults={duration:1000,axis:'y',event:'click',stop:true,target:window};i.hash=function(a){if(location.hash){a=$.extend({},i.defaults,a);a.hash=false;if(a.reset){var d=a.duration;delete a.duration;$(a.target).scrollTo(0,a);a.duration=d}scroll(0,location,a)}};$.fn.localScroll=function(b){b=$.extend({},i.defaults,b);return b.lazy?this.bind(b.event,function(e){var a=$([e.target,e.target.parentNode]).filter(filter)[0];if(a)scroll(e,a,b)}):this.find('a,area').filter(filter).bind(b.event,function(e){scroll(e,this,b)}).end().end();function filter(){return!!this.href&&!!this.hash&&this.href.replace(this.hash,'')==h&&(!b.filter||$(this).is(b.filter))}};function scroll(e,a,b){var c=a.hash.slice(1),elem=document.getElementById(c)||document.getElementsByName(c)[0];if(!elem)return;if(e)e.preventDefault();var d=$(b.target);if(b.lock&&d.is(':animated')||b.onBefore&&b.onBefore(e,elem,d)===false)return;if(b.stop)d._scrollable().stop(true);if(b.hash){var f=b.offset;f=f&&f.top||f||0;var g=elem.id==c?'id':'name',$a=$('<a> </a>').attr(g,c).css({position:'absolute',top:$(window).scrollTop()+f,left:$(window).scrollLeft()});elem[g]='';$('body').prepend($a);location=a.hash;$a.remove();elem[g]=c}d.scrollTo(elem,b).trigger('notify.serialScroll',[elem])}})(jQuery);
|
@ -96,8 +96,10 @@ describe('The container component', function() {
|
|||||||
*/
|
*/
|
||||||
it('should provide access to the main and detail component', function() {
|
it('should provide access to the main and detail component', function() {
|
||||||
createDOM();
|
createDOM();
|
||||||
|
|
||||||
rjsmock.registerDependencies({
|
rjsmock.registerDependencies({
|
||||||
'URIjs/URI' : URI
|
'URIjs/URI' : URI,
|
||||||
|
'icinga/util/url' : 'icinga/util/url.js'
|
||||||
});
|
});
|
||||||
requireNew('icinga/components/container.js');
|
requireNew('icinga/components/container.js');
|
||||||
var Container = rjsmock.getDefine();
|
var Container = rjsmock.getDefine();
|
||||||
@ -109,41 +111,4 @@ describe('The container component', function() {
|
|||||||
$('#icingadetail')[0], 'Assert the DOM of the detail container being #icingadetail');
|
$('#icingadetail')[0], 'Assert the DOM of the detail container being #icingadetail');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dynamic Url update
|
|
||||||
*/
|
|
||||||
it('should automatically update its part of the URL if assigning a new URL', function() {
|
|
||||||
rjsmock.registerDependencies({
|
|
||||||
'URIjs/URI' : URI
|
|
||||||
});
|
|
||||||
requireNew('icinga/components/container.js');
|
|
||||||
createDOM();
|
|
||||||
var Container = rjsmock.getDefine();
|
|
||||||
var url = Container.getMainContainer().updateContainerHref('/some/other/url?test');
|
|
||||||
window.setWindowUrl(url);
|
|
||||||
Container.getMainContainer().containerDom.attr('data-icinga-href').should.equal('/some/other/url?test');
|
|
||||||
|
|
||||||
url.should.equal(
|
|
||||||
'/some/other/url?test',
|
|
||||||
'Assert the main container updating the url correctly');
|
|
||||||
|
|
||||||
url = Container.getDetailContainer().updateContainerHref('/some/detail/url?test');
|
|
||||||
window.setWindowUrl(url);
|
|
||||||
|
|
||||||
Container.getDetailContainer().containerDom.attr('data-icinga-href').should.equal('/some/detail/url?test');
|
|
||||||
url.should.equal(
|
|
||||||
'/some/other/url?test&detail=' + encodeURIComponent('/some/detail/url?test'),
|
|
||||||
'Assert the detail container only updating the "detail" portion of the URL'
|
|
||||||
);
|
|
||||||
|
|
||||||
url = Container.getMainContainer().updateContainerHref('/some/other2/url?test=test');
|
|
||||||
|
|
||||||
window.setWindowUrl(Container.getMainContainer().getContainerHref(window.location.href));
|
|
||||||
Container.getMainContainer().containerDom.attr('data-icinga-href').should.equal('/some/other2/url?test=test');
|
|
||||||
url.should.equal(
|
|
||||||
'/some/other2/url?test=test&detail=' + encodeURIComponent('/some/detail/url?test'),
|
|
||||||
'Assert the main container keeping the detail portion untouched if being assigned a new URL'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -107,7 +107,7 @@ class TabTest extends PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals(
|
$this->assertEquals(
|
||||||
1,
|
1,
|
||||||
preg_match(
|
preg_match(
|
||||||
'/<li *><a href="\/my\/url">Title text<\/a><\/li>/i',
|
'/<li *><a href="\/my\/url".*>Title text<\/a><\/li>/i',
|
||||||
$html
|
$html
|
||||||
),
|
),
|
||||||
'Asserting an url being rendered inside an HTML anchor. got ' . $html
|
'Asserting an url being rendered inside an HTML anchor. got ' . $html
|
||||||
|
Loading…
x
Reference in New Issue
Block a user