From 4b55bcf8b6e86d56aa0ba50faf6166ca0a00e4d6 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Tue, 2 Sep 2014 12:24:29 +0200 Subject: [PATCH 01/12] Add tooltips to bar charts Add a class to format and populate tooltips from graph data sets and implement those tooltips in the ChartController. --- library/Icinga/Chart/Graph/BarGraph.php | 104 ++++++++++--- library/Icinga/Chart/Graph/StackedGraph.php | 4 + library/Icinga/Chart/Graph/Tooltip.php | 144 ++++++++++++++++++ library/Icinga/Chart/GridChart.php | 40 ++++- .../controllers/ChartController.php | 23 ++- 5 files changed, 286 insertions(+), 29 deletions(-) create mode 100644 library/Icinga/Chart/Graph/Tooltip.php diff --git a/library/Icinga/Chart/Graph/BarGraph.php b/library/Icinga/Chart/Graph/BarGraph.php index 968e4c0f7..3d6a5c234 100644 --- a/library/Icinga/Chart/Graph/BarGraph.php +++ b/library/Icinga/Chart/Graph/BarGraph.php @@ -16,6 +16,13 @@ use Icinga\Chart\Render\RenderContext; */ class BarGraph extends Styleable implements Drawable { + /** + * The dataset order + * + * @var int + */ + private $order = 0; + /** * The width of the bars. * @@ -30,14 +37,37 @@ class BarGraph extends Styleable implements Drawable */ private $dataSet; + /** + * The tooltips + * + * @var + */ + private $tooltips; + + /** + * All graphs + * + * @var + */ + private $graphs; + /** * Create a new BarGraph with the given dataset * - * @param array $dataSet An array of datapoints + * @param array $dataSet An array of data points + * @param int $order The graph number displayed by this BarGraph + * @param array $tooltips The tooltips to display for each value */ - public function __construct(array $dataSet) - { + public function __construct( + array $dataSet, + array &$graphs, + $order, + array $tooltips = null + ) { + $this->order = $order; $this->dataSet = $dataSet; + $this->tooltips = $tooltips; + $this->graphs = $graphs; } /** @@ -56,6 +86,30 @@ class BarGraph extends Styleable implements Drawable } } + /** + * Draw a single rectangle + * + * @param array $point The + * @param null $index + * @param string $fill The fill color to use + * @param $strokeWidth + * + * @return Rect + */ + private function drawSingleBar($point, $index = null, $fill, $strokeWidth) + { + $rect = new Rect($point[0] - ($this->barWidth / 2), $point[1], $this->barWidth, 100 - $point[1]); + $rect->setFill($fill); + $rect->setStrokeWidth($strokeWidth); + $rect->setStrokeColor('black'); + if (isset($index)) { + $rect->setAttribute('data-icinga-graph-index', $index); + } + $rect->setAttribute('data-icinga-graph-type', 'bar'); + $rect->setAdditionalStyle('clip-path: url(#clip);'); + return $rect; + } + /** * Render this BarChart * @@ -68,23 +122,33 @@ class BarGraph extends Styleable implements Drawable $doc = $ctx->getDocument(); $group = $doc->createElement('g'); $idx = 0; - foreach ($this->dataSet as $point) { - $rect = new Rect($point[0] - 2, $point[1], 4, 100 - $point[1]); - $rect->setFill($this->fill); - $rect->setStrokeWidth($this->strokeWidth); - $rect->setStrokeColor('black'); - $rect->setAttribute('data-icinga-graph-index', $idx++); - $rect->setAttribute('data-icinga-graph-type', 'bar'); - $rect->setAdditionalStyle('clip-path: url(#clip);'); - /*$rect->setAnimation( - new Animation( - 'y', - $ctx->yToAbsolute(100), - $ctx->yToAbsolute($point[1]), - rand(1, 1.5)/2 - ) - );*/ - $group->appendChild($rect->toSvg($ctx)); + foreach ($this->dataSet as $x => $point) { + // add white background bar, to prevent other bars from altering transparency effects + $bar = $this->drawSingleBar($point, $idx++, 'white', $this->strokeWidth, $idx)->toSvg($ctx); + $group->appendChild($bar); + + // draw actual bar + $bar = $this->drawSingleBar($point, null, $this->fill, $this->strokeWidth, $idx)->toSvg($ctx); + $bar->setAttribute('class', 'chart-data'); + if (isset($this->tooltips[$x])) { + $data = array( + 'label' => isset($this->graphs[$this->order]['label']) ? + strtolower($this->graphs[$this->order]['label']) : '', + 'color' => isset($this->graphs[$this->order]['color']) ? + strtolower($this->graphs[$this->order]['color']) : '#fff' + ); + $format = isset($this->graphs[$this->order]['tooltip']) + ? $this->graphs[$this->order]['tooltip'] : null; + $bar->setAttribute( + 'title', + $this->tooltips[$x]->renderNoHtml($this->order, $data, $format) + ); + $bar->setAttribute( + 'title-rich', + $this->tooltips[$x]->render($this->order, $data, $format) + ); + } + $group->appendChild($bar); } return $group; } diff --git a/library/Icinga/Chart/Graph/StackedGraph.php b/library/Icinga/Chart/Graph/StackedGraph.php index ae4f593e2..4339b8c8d 100644 --- a/library/Icinga/Chart/Graph/StackedGraph.php +++ b/library/Icinga/Chart/Graph/StackedGraph.php @@ -41,6 +41,10 @@ class StackedGraph implements Drawable if (!isset($this->points[$x])) { $this->points[$x] = 0; } + // store old y-value for displaying the actual (non-aggregated) + // value in the tooltip + $point[2] = $point[1]; + $this->points[$x] += $point[1]; $point[1] = $this->points[$x]; } diff --git a/library/Icinga/Chart/Graph/Tooltip.php b/library/Icinga/Chart/Graph/Tooltip.php new file mode 100644 index 000000000..b630c7c61 --- /dev/null +++ b/library/Icinga/Chart/Graph/Tooltip.php @@ -0,0 +1,144 @@ + + *
  • Global properties
  • : Key-value pairs that stay the same every time render is called, and are + * passed to an instance in the constructor. + *
  • Aggregated properties
  • : Global properties that are created automatically from + * all attached data points. + *
  • Local properties
  • : Key-value pairs that only apply to a single data point and + * are passed to the render-function. + * + */ +class Tooltip +{ + /** + * The default format string used + * when no other format is specified + * + * @var string + */ + private $defaultFormat; + + /** + * All aggregated points + * + * @var array + */ + private $points = array(); + + /** + * Contains all static replacements + * + * @var array + */ + private $data = array( + 'sum' => 0 + ); + + /** + * Used to format the displayed tooltip. + * + * @var string + */ + protected $tooltipFormat; + + /** + * Create a new tooltip with the specified default format string + * + * Allows you to set the global data for this tooltip, that is displayed every + * time render is called. + * + * @param array $data Map of global properties + * @param string $format The default format string + */ + public function __construct ( + $data = array(), + $format = '{title}
    {value} of {sum} {label}' + ) { + $this->data = array_merge($this->data, $data); + $this->defaultFormat = $format; + } + + /** + * Add a single data point to update the aggregated properties for this tooltip + * + * @param $point array Contains the (x,y) values of the data set + */ + public function addDataPoint($point) + { + // set x-value + if (!isset($this->data['title'])) { + $this->data['title'] = $point[0]; + } + + // aggregate y-values + $y = (int)$point[1]; + if (isset($point[2])) { + // load original value in case value already aggregated + $y = (int)$point[2]; + } + + if (!isset($this->data['min']) || $this->data['min'] > $y) { + $this->data['min'] = $y; + } + if (!isset($this->data['max']) || $this->data['max'] < $y) { + $this->data['max'] = $y; + } + $this->data['sum'] += $y; + $this->points[] = $y; + } + + /** + * Format the tooltip for a certain data point + * + * @param array $order Which data set to render + * @param array $data The local data for this tooltip + * @param string $format Use a custom format string for this data set + * + * @return mixed|string The tooltip value + */ + public function render($order, $data = array(), $format = null) + { + if (isset($format)) { + $str = $format; + } else { + $str = $this->defaultFormat; + } + $data['value'] = $this->points[$order]; + foreach (array_merge($this->data, $data) as $key => $value) { + $str = str_replace('{' . $key . '}', $value, $str); + } + return $str; + } + + /** + * Format the tooltip for a certain data point but remove all + * occurring html tags + * + * This is useful for rendering clean tooltips on client without JavaScript + * + * @param array $order Which data set to render + * @param array $data The local data for this tooltip + * @param string $format Use a custom format string for this data set + * + * @return mixed|string The tooltip value, without any HTML tags + */ + public function renderNoHtml($order, $data, $format) + { + return strip_tags($this->render($order, $data, $format)); + } +} \ No newline at end of file diff --git a/library/Icinga/Chart/GridChart.php b/library/Icinga/Chart/GridChart.php index ef1682921..190e09bf9 100644 --- a/library/Icinga/Chart/GridChart.php +++ b/library/Icinga/Chart/GridChart.php @@ -10,6 +10,7 @@ use Icinga\Chart\Axis; use Icinga\Chart\Graph\BarGraph; use Icinga\Chart\Graph\LineGraph; use Icinga\Chart\Graph\StackedGraph; +use Icinga\Chart\Graph\Tooltip; use Icinga\Chart\Primitive\Canvas; use Icinga\Chart\Primitive\Rect; use Icinga\Chart\Primitive\Path; @@ -74,6 +75,16 @@ class GridChart extends Chart */ private $stacks = array(); + /** + * An associative array containing all Tooltips used to render the titles + * + * Each tooltip represents the summary for all y-values of a certain x-value + * in the grid chart + * + * @var Tooltip + */ + private $tooltips = array(); + /** * Check if the current dataset has the proper structure for this chart. * @@ -169,6 +180,26 @@ class GridChart extends Chart $this->legend->addDataset($graph); } } + $this->initTooltips($data); + } + + + private function initTooltips($data) + { + foreach ($data as &$graph) { + foreach ($graph['data'] as $x => $point) { + if (!array_key_exists($x, $this->tooltips)) { + $this->tooltips[$x] = new Tooltip( + array( + 'color' => $graph['color'], + + ) + + ); + } + $this->tooltips[$x]->addDataPoint($point); + } + } } /** @@ -353,11 +384,16 @@ class GridChart extends Chart foreach ($this->graphs as $axisName => $graphs) { $axis = $this->axis[$axisName]; $graphObj = null; - foreach ($graphs as $graph) { + foreach ($graphs as $dataset => $graph) { // determine the type and create a graph object for it switch ($graph['graphType']) { case self::TYPE_BAR: - $graphObj = new BarGraph($axis->transform($graph['data'])); + $graphObj = new BarGraph( + $axis->transform($graph['data']), + $graphs, + $dataset, + $this->tooltips + ); break; case self::TYPE_LINE: $graphObj = new LineGraph($axis->transform($graph['data'])); diff --git a/modules/monitoring/application/controllers/ChartController.php b/modules/monitoring/application/controllers/ChartController.php index 305ff92bc..af6240259 100644 --- a/modules/monitoring/application/controllers/ChartController.php +++ b/modules/monitoring/application/controllers/ChartController.php @@ -154,30 +154,35 @@ class Monitoring_ChartController extends Controller ->setXAxis(new \Icinga\Chart\Unit\StaticAxis()) ->setAxisMin(null, 0); + $tooltip = t('{title}:
    {value} of {sum} services are {label}'); $this->view->chart->drawBars( array( 'label' => t('Ok'), 'color' => '#44bb77', 'stack' => 'stack1', - 'data' => $okBars + 'data' => $okBars, + 'tooltip' => $tooltip ), array( 'label' => t('Warning'), 'color' => '#ffaa44', 'stack' => 'stack1', - 'data' => $warningBars + 'data' => $warningBars, + 'tooltip' => $tooltip ), array( 'label' => t('Critical'), 'color' => '#ff5566', 'stack' => 'stack1', - 'data' => $critBars + 'data' => $critBars, + 'tooltip' => $tooltip ), array( 'label' => t('Unknown'), 'color' => '#dd66ff', 'stack' => 'stack1', - 'data' => $unknownBars + 'data' => $unknownBars, + 'tooltip' => $tooltip ) ); } @@ -201,6 +206,7 @@ class Monitoring_ChartController extends Controller $hostgroup->hosts_unreachable_unhandled ); } + $tooltip = t('{title}:
    {value} of {sum} hosts are {label}'); $this->view->chart = new GridChart(); $this->view->chart->alignTopLeft(); $this->view->chart->setAxisLabel('', t('Hosts')) @@ -211,19 +217,22 @@ class Monitoring_ChartController extends Controller 'label' => t('Up'), 'color' => '#44bb77', 'stack' => 'stack1', - 'data' => $upBars + 'data' => $upBars, + 'tooltip' => $tooltip ), array( 'label' => t('Down'), 'color' => '#ff5566', 'stack' => 'stack1', - 'data' => $downBars + 'data' => $downBars, + 'tooltip' => $tooltip ), array( 'label' => t('Unreachable'), 'color' => '#dd66ff', 'stack' => 'stack1', - 'data' => $unreachableBars + 'data' => $unreachableBars, + 'tooltip' => $tooltip ) ); } From 8823db4214a23eb0cbf8ea8dae49d0a3143bd048 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Tue, 2 Sep 2014 12:56:29 +0200 Subject: [PATCH 02/12] Add documentation for bar chart tooltips --- doc/graphs.md | 74 ++++++++++++++++++++++++++++++++++- doc/res/GraphExample#7.1.png | Bin 0 -> 7438 bytes 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 doc/res/GraphExample#7.1.png diff --git a/doc/graphs.md b/doc/graphs.md index 46623f74c..3c898f7a6 100644 --- a/doc/graphs.md +++ b/doc/graphs.md @@ -238,6 +238,77 @@ the labels to show you can use the 'disableLegend()' call on the GridChart objec ![Various Line Graph Options][graph7] + +### Tooltips + +It is possible to specify custom tooltip format strings when creating bar charts. +Tooltips provide information about the points of each bar chart column, by aggregating +the values of all data sets with the same x-coordinate. + +When no custom format string is given, a sane default format string is used, but its usually +clearer for the user to describe the data of each chart more accurately with a custom one. + + +**Example #9.1: Bar Charts with custom tooltips** + + $this->chart->drawBars( + array( + 'label' => 'Hosts critical', + 'palette' => Palette::PROBLEM, + 'stack' => 'stack1', + 'data' => $data2, + 'tooltip' => '{title}
    {value} of {sum} hosts are ok.' + ), + array( + 'label' => 'Hosts warning', + 'stack' => 'stack1', + 'palette' => Palette::WARNING, + 'data' => $data, + 'tooltip' => '{title}
    Oh no, {value} of {sum} hosts are down!' + ) + ); + + +As you can see, you can specify a format string for each data set, which allows you to +pass a custom message for all "down" hosts, one custom message for all "Ok" hosts and so on. +In contrast to that, the aggregation of values works on a column basis and will give you the +sum of all y-values with the same x-coordinate and not the aggregation of all values of the data set. + +#### Rich Tooltips + +It is also possible to use HTML in the tooltip strings to create rich tooltip markups, which can +be useful to provide extended output that spans over multiple lines. Please keep in mind that +users without JavaScript will see the tooltip with all of its html-tags stripped. + +![Various Line Graph Options][graph7.1] + +#### Available replacements + +The available replacements depend on the used chart type, since the tooltip data is + instantiated and populated by the chart. All bar graphs have the following replacements available: + +Aggregated values, are calculated from the data points of each column: + + - sum: The amount of all Y-values of the current column + - max: The biggest occurring Y-value of the current column + - min: The smallest occurring Y-value of the current column + + +Column values are also defined by the current column, but are not +the product of any aggregation + + - title: The x-value of the current column + + +Row values are defined by the properties the current data set, and are only useful for rendering the +generic tooltip correctly, since you could also just directly write +those values into your custom tooltip. + + - label: The name of the current data set + - color: The color of this data set + + + ## Pie Charts ### The PieChart Object @@ -317,5 +388,6 @@ Rendering is straightforward, assuming $svg is the PieChart/GridChart object, yo [graph5]: res/GraphExample#5.png [graph6]: res/GraphExample#6.png [graph7]: res/GraphExample#7.png +[graph7.1]: res/GraphExample#7.1.png [graph8]: res/GraphExample#8.png -[graph9]: res/GraphExample#9.png \ No newline at end of file +[graph9]: res/GraphExample#9.png diff --git a/doc/res/GraphExample#7.1.png b/doc/res/GraphExample#7.1.png new file mode 100644 index 0000000000000000000000000000000000000000..8f678324753383ced2e0e97351cd379be18f3002 GIT binary patch literal 7438 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 literal 0 HcmV?d00001 From 26339b128a9815f13c27a2042bd59c42c0a58d29 Mon Sep 17 00:00:00 2001 From: Alexander Fuhr Date: Tue, 2 Sep 2014 13:16:21 +0200 Subject: [PATCH 03/12] Add disabled property and implement the functionality refs #6986 --- .../Icinga/Web/Widget/Dashboard/Component.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/library/Icinga/Web/Widget/Dashboard/Component.php b/library/Icinga/Web/Widget/Dashboard/Component.php index 87f756282..7410c6210 100644 --- a/library/Icinga/Web/Widget/Dashboard/Component.php +++ b/library/Icinga/Web/Widget/Dashboard/Component.php @@ -42,6 +42,13 @@ class Component extends AbstractWidget */ private $pane; + /** + * The disabled option is used to "delete" default dashlets provided by modules + * + * @var bool + */ + private $disabled = false; + /** * The template string used for rendering this widget * @@ -117,6 +124,26 @@ EOD; return $this; } + /** + * Set the disabled property + * + * @param boolean $disabled + */ + public function setDisabled($disabled) + { + $this->disabled = $disabled; + } + + /** + * Get the disabled property + * + * @return boolean + */ + public function getDisabled() + { + return $this->disabled; + } + /** * Return this component's structure as array * @@ -136,6 +163,10 @@ EOD; */ public function render() { + if ($this->disabled === true) { + return ''; + } + $view = $this->view(); $url = clone($this->url); $url->setParam('view', 'compact'); From 7cfc051228588f1635f478184274c9d436929a1d Mon Sep 17 00:00:00 2001 From: Alexander Fuhr Date: Wed, 3 Sep 2014 14:36:04 +0200 Subject: [PATCH 04/12] Add tests for Dashboard and fix doc blocks refs #6986 --- library/Icinga/Web/Widget/Dashboard.php | 47 +- library/Icinga/Web/Widget/Dashboard/Pane.php | 12 +- .../library/Icinga/Widget/DashboardTest.php | 509 ++++++++++++++++++ 3 files changed, 546 insertions(+), 22 deletions(-) create mode 100644 test/php/library/Icinga/Widget/DashboardTest.php diff --git a/library/Icinga/Web/Widget/Dashboard.php b/library/Icinga/Web/Widget/Dashboard.php index d8b09b15a..a86204d87 100644 --- a/library/Icinga/Web/Widget/Dashboard.php +++ b/library/Icinga/Web/Widget/Dashboard.php @@ -8,7 +8,6 @@ use Icinga\Application\Icinga; use Icinga\Application\Config as IcingaConfig; use Icinga\Exception\ConfigurationError; use Icinga\Exception\ProgrammingError; -use Icinga\Web\Widget\AbstractWidget; use Icinga\Web\Widget\Dashboard\Pane; use Icinga\Web\Widget\Dashboard\Component as DashboardComponent; use Icinga\Web\Url; @@ -96,7 +95,7 @@ class Dashboard extends AbstractWidget $current = $this->panes[$pane->getName()]; $current->addComponents($pane->getComponents()); } else { - $this->panes = array_filter(array_merge($this->panes, $panes)); + $this->panes[$pane->getName()] = $pane; } } @@ -128,6 +127,16 @@ class Dashboard extends AbstractWidget return $this->tabs; } + /** + * Return all panes of this dashboard + * + * @return array + */ + public function getPanes() + { + return $this->panes; + } + /** * Populate this dashboard via the given configuration file * @@ -164,9 +173,9 @@ class Dashboard extends AbstractWidget * * @TODO: Should only allow component objects to be added directly as soon as we store more information * - * @param string $pane The pane to add the component to - * @param Component|string $component The component to add or the title of the newly created component - * @param $url The url to use for the component + * @param string $pane The pane to add the component to + * @param Component|string $component The component to add or the title of the newly created component + * @param string|null $url The url to use for the component * * @return self */ @@ -198,20 +207,14 @@ class Dashboard extends AbstractWidget } /** - * Return true if a pane doesn't exist or doesn't have any components in it - * - * @param string $pane The name of the pane to check for emptyness + * Check if this dashboard has a specific pane * + * @param $pane string The name of the pane * @return bool */ - public function isEmptyPane($pane) + public function hasPane($pane) { - $paneObj = $this->getPane($pane); - if ($paneObj === null) { - return true; - } - $cmps = $paneObj->getComponents(); - return !empty($cmps); + return array_key_exists($pane, $this->panes); } /** @@ -305,11 +308,11 @@ class Dashboard extends AbstractWidget return $active; } + /** + * @see determineActivePane() + */ public function getActivePane() { - if ($active = $this->getTabs()->getActiveName()) { - return $this->getPane($active); - } return $this->determineActivePane(); } @@ -323,10 +326,12 @@ class Dashboard extends AbstractWidget $active = $this->getTabs()->getActiveName(); if (! $active) { if ($active = Url::fromRequest()->getParam($this->tabParam)) { - if ($this->isEmptyPane($active)) { - $active = $this->setDefaultPane(); - } else { + if ($this->hasPane($active)) { $this->activate($active); + } else { + throw new ProgrammingError( + 'Try to get an inexistent pane.' + ); } } else { $active = $this->setDefaultPane(); diff --git a/library/Icinga/Web/Widget/Dashboard/Pane.php b/library/Icinga/Web/Widget/Dashboard/Pane.php index 39ad1fda3..45bd9c558 100644 --- a/library/Icinga/Web/Widget/Dashboard/Pane.php +++ b/library/Icinga/Web/Widget/Dashboard/Pane.php @@ -39,7 +39,7 @@ class Pane extends AbstractWidget /** * Create a new pane * - * @param $name The pane to create + * @param string $name The pane to create */ public function __construct($name) { @@ -92,6 +92,16 @@ class Pane extends AbstractWidget return array_key_exists($title, $this->components); } + /** + * Checks if the current pane has any components + * + * @return bool + */ + public function hasComponents() + { + return ! empty($this->components); + } + /** * Return a component with the given name if existing * diff --git a/test/php/library/Icinga/Widget/DashboardTest.php b/test/php/library/Icinga/Widget/DashboardTest.php new file mode 100644 index 000000000..cdb4d8ff2 --- /dev/null +++ b/test/php/library/Icinga/Widget/DashboardTest.php @@ -0,0 +1,509 @@ +shouldReceive('escape'); + + return $mock; + } +} + +class DashboardWithPredefinableActiveName extends Dashboard +{ + public $activeName = ''; + + public function getTabs() + { + return Mockery::mock('Icinga\Web\Widget\Tabs') + ->shouldReceive('getActiveName')->andReturn($this->activeName) + ->shouldReceive('activate') + ->getMock(); + } +} + +class DashboardTest extends BaseTestCase +{ + public function tearDown() + { + parent::tearDown(); + Mockery::close(); // Necessary because some tests run in a separate process + } + + protected function setupIcingaMock(\Zend_Controller_Request_Abstract $request) + { + $moduleMock = Mockery::mock('Icinga\Application\Modules\Module'); + $moduleMock->shouldReceive('getPaneItems')->andReturn(array( + 'test-pane' => new Pane('Test Pane') + )); + + $moduleManagerMock = Mockery::mock('Icinga\Application\Modules\Manager'); + $moduleManagerMock->shouldReceive('getLoadedModules')->andReturn(array( + 'test-module' => $moduleMock + )); + + $bootstrapMock = Mockery::mock('Icinga\Application\ApplicationBootstrap')->shouldDeferMissing(); + $bootstrapMock->shouldReceive('getFrontController->getRequest')->andReturnUsing( + function () use ($request) { return $request; } + )->shouldReceive('getApplicationDir')->andReturn(self::$appDir); + + $bootstrapMock->shouldReceive('getModuleManager')->andReturn($moduleManagerMock); + + Icinga::setApp($bootstrapMock, true); + } + + public function testWhetherCreatePaneCreatesAPane() + { + $dashboard = new Dashboard(); + $pane = $dashboard->createPane('test')->getPane('test'); + + $this->assertEquals('test', $pane->getTitle(), 'Dashboard::createPane() could not create a pane'); + } + + /** + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testMergePanesWithDifferentPaneName() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $dashboard->createPane('test2'); + + $panes = array( + new Pane('test1a'), + new Pane('test2a') + ); + + $dashboard->mergePanes($panes); + + $this->assertCount(4, $dashboard->getPanes(), 'Dashboard::mergePanes() could not merge different panes'); + } + + /** + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testMergePanesWithSamePaneName() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $dashboard->createPane('test2'); + + $panes = array( + new Pane('test1'), + new Pane('test3') + ); + + $dashboard->mergePanes($panes); + + $this->assertCount(3, $dashboard->getPanes(), 'Dashboard::mergePanes() could not merge same panes'); + } + + /** + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testWhetherGetPaneReturnsAPaneByName() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + + $pane = $dashboard->getPane('test1'); + + $this->assertEquals( + 'test1', + $pane->getName(), + 'Dashboard:getPane() could not return pane by name' + ); + } + + /** + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testLoadPaneItemsProvidedByEnabledModules() + { + $dashboard = Dashboard::load(); + + $this->assertCount( + 1, + $dashboard->getPanes(), + 'Dashboard::load() could not load panes from enabled modules' + ); + } + + /** + * @expectedException \Icinga\Exception\ProgrammingError + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testWhetherGetPaneThrowsAnExceptionOnNotExistentPaneName() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + + $dashboard->getPane('test2'); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherRenderNotRendersPanesDisabledComponent() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + $component = new ComponentWithMockedView('test', 'test', $pane); + $component->setDisabled(true); + $pane->addComponent($component); + + $rendered = $dashboard->render(); + + $greaterThanOne = strlen($rendered) > 1; + + $this->assertFalse( + $greaterThanOne, + 'Dashboard::render() disabled component is rendered, but should not' + ); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherRenderRendersPanesEnabledComponent() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + $component = new ComponentWithMockedView('test', 'test', $pane); + $pane->addComponent($component); + + $rendered = $dashboard->render(); + + $greaterThanOne = strlen($rendered) > 1; + + $this->assertTrue( + $greaterThanOne, + 'Dashboard::render() could not render enabled component' + ); + } + + public function testWhetherRenderNotRendersNotExistentPane() + { + $dashboard = new Dashboard(); + + $rendered = $dashboard->render(); + + $greaterThanOne = strlen($rendered) > 1; + + $this->assertFalse( + $greaterThanOne, + 'Dashboard::render() not existent pane ist rendered, but should not' + ); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherGetPaneKeyTitleArrayReturnFormedArray() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1')->getPane('test1')->setTitle('Test1'); + $dashboard->createPane('test2')->getPane('test2')->setTitle('Test2'); + + $result = $dashboard->getPaneKeyTitleArray(); + + $expected = array( + 'test1' => 'Test1', + 'test2' => 'Test2' + ); + + $this->assertEquals( + $expected, + $result, + 'Dashboard::getPaneKeyTitleArray() could not return valid expectation' + ); + } + + /** + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testWhetherHasPanesHasPanes() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $dashboard->createPane('test2'); + + $hasPanes = $dashboard->hasPanes(); + + $this->assertTrue($hasPanes, 'Dashboard::hasPanes() could not return valid expectation'); + } + + public function testWhetherHasPanesHasNoPanes() + { + $dashboard = new Dashboard(); + + $hasPanes = $dashboard->hasPanes(); + + $this->assertFalse($hasPanes, 'Dashboard::hasPanes() has panes but should not'); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherRemoveComponentRemovesComponent() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + + $component = new Component('test', 'test', $pane); + $pane->addComponent($component); + + $component2 = new Component('test2', 'test2', $pane); + $pane->addComponent($component2); + + $dashboard->removeComponent('test1', 'test'); + + $result = $dashboard->getPane('test1')->hasComponent('test'); + + $this->assertFalse( + $result, + 'Dashboard::removeComponent() could not remove component from the pane' + ); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherRemoveComponentRemovesComponentByConcatenation() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + + $component = new Component('test', 'test', $pane); + $pane->addComponent($component); + + $component2 = new Component('test2', 'test2', $pane); + $pane->addComponent($component2); + + $dashboard->removeComponent('test1.test', null); + + $result = $dashboard->getPane('test1')->hasComponent('test'); + + $this->assertFalse( + $result, + 'Dashboard::removeComponent() could not remove component from the pane' + ); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherToArrayReturnsDashboardStructureAsArray() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + + $component = new Component('test', 'test', $pane); + $pane->addComponent($component); + + $result = $dashboard->toArray(); + + $expected = array( + 'test1' => array( + 'title' => 'test1' + ), + 'test1.test' => array( + 'url' => 'test' + ) + ); + + $this->assertEquals( + $expected, + $result, + 'Dashboard::toArray() could not return valid expectation' + ); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherSetComponentUrlUpdatesTheComponentUrl() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + $component = new Component('test', 'test', $pane); + $pane->addComponent($component); + + $dashboard->setComponentUrl('test1', 'test', 'new'); + + $this->assertEquals( + 'new', + $component->getUrl()->getPath(), + 'Dashboard::setComponentUrl() could not return valid expectation' + ); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherSetComponentUrlUpdatesTheComponentUrlConcatenation() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + $component = new Component('test', 'test', $pane); + $pane->addComponent($component); + + $dashboard->setComponentUrl('test1.test', null, 'new'); + + $this->assertEquals( + 'new', + $component->getUrl()->getPath(), + 'Dashboard::setComponentUrl() could not return valid expectation' + ); + } + + /** + * @depends testWhetherGetPaneReturnsAPaneByName + */ + public function testWhetherSetComponentUrlUpdatesTheComponentUrlNotExistentPane() + { + $dashboard = new Dashboard(); + $dashboard->createPane('test1'); + $pane = $dashboard->getPane('test1'); + $component = new Component('test', 'test', $pane); + $pane->addComponent($component); + + $dashboard->setComponentUrl('test3.test', null, 'new'); + + $result = $dashboard->getPane('test3')->getComponent('test'); + + $this->assertEquals( + 'new', + $result->getUrl()->getPath(), + 'Dashboard::setComponentUrl() could not return valid expectation' + ); + } + + /** + * @expectedException \Icinga\Exception\ConfigurationError + */ + public function testWhetherDetermineActivePaneThrowsAnExceptionIfCouldNotDetermine() + { + $dashboard = new Dashboard(); + $dashboard->determineActivePane(); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + * @expectedException \Icinga\Exception\ProgrammingError + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testWhetherDetermineActivePaneThrowsAnExceptionIfCouldNotDetermineInvalidPane() + { + $dashboard = new DashboardWithPredefinableActiveName(); + $dashboard->createPane('test1'); + + Mockery::mock('alias:Icinga\Web\Url') + ->shouldReceive('fromRequest->getParam')->andReturn('test2'); + + $dashboard->determineActivePane(); + } + + /** + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testWhetherDetermineActivePaneDeterminesActivePane() + { + $dashboard = new DashboardWithPredefinableActiveName(); + $dashboard->activeName = 'test2'; + $dashboard->createPane('test1'); + $dashboard->createPane('test2'); + + $activePane = $dashboard->determineActivePane(); + + $this->assertEquals( + 'test2', + $activePane->getTitle(), + 'Dashboard::determineActivePane() could not determine active pane' + ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testWhetherDetermineActivePaneDeterminesActiveValidPane() + { + $dashboard = new DashboardWithPredefinableActiveName(); + $dashboard->createPane('test1'); + $dashboard->createPane('test2'); + + Mockery::mock('alias:Icinga\Web\Url') + ->shouldReceive('fromRequest->getParam')->andReturn('test2'); + + $activePane = $dashboard->determineActivePane(); + + $this->assertEquals( + 'test2', + $activePane->getTitle(), + 'Dashboard::determineActivePane() could not determine active pane' + ); + } + + /** + * @depends testWhetherCreatePaneCreatesAPane + */ + public function testWhetherGetActivePaneReturnsActivePane() + { + $dashboard = new DashboardWithPredefinableActiveName(); + $dashboard->activeName = 'test2'; + $dashboard->createPane('test1'); + $dashboard->createPane('test2'); + + $activePane = $dashboard->getActivePane(); + + $this->assertEquals( + 'test2', + $activePane->getTitle(), + 'Dashboard::determineActivePane() could not get expected active pane' + ); + } + + public function testWhetherLoadConfigPanes() + { + $this->markTestIncomplete( + 'Dashboard::loadConfigPanes() is not fully implemented yet or rather not used' + ); + } + + /** + * @depends testWhetherLoadConfigPanes + */ + public function testWhetherReadConfigPopulatesDashboard() + { + $this->markTestIncomplete( + 'Dashboard::readConfig() is not fully implemented yet or rather not used' + ); + } +} From 65fb246b7e588e233a78d28692b5fa2d32439167 Mon Sep 17 00:00:00 2001 From: Thomas Gelf Date: Wed, 3 Sep 2014 15:03:24 +0200 Subject: [PATCH 05/12] FilterTest: fix complex filter example The filter in the test was not a valid filter. fixes #7069 --- test/php/library/Icinga/Data/Filter/FilterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/php/library/Icinga/Data/Filter/FilterTest.php b/test/php/library/Icinga/Data/Filter/FilterTest.php index cc91522dd..dbd640b91 100644 --- a/test/php/library/Icinga/Data/Filter/FilterTest.php +++ b/test/php/library/Icinga/Data/Filter/FilterTest.php @@ -182,7 +182,7 @@ class FilterTest extends BaseTestCase public function testComplexFilterFromQueryString() { - $q = 'host=localhost|nohost*&problem&service=*www*|ups*&state!=1&!handled'; + $q = '(host=localhost|host=nohost*)&problem&(service=*www*|service=ups*)&state!=1&!handled'; $filter = Filter::fromQueryString($q); $this->assertFalse($filter->matches($this->row(0))); $this->assertTrue($filter->matches($this->row(1))); From 1d812114272eefe07e1eed536401b9a853fa6fce Mon Sep 17 00:00:00 2001 From: Alexander Fuhr Date: Wed, 3 Sep 2014 15:11:11 +0200 Subject: [PATCH 06/12] Move DashboardTest in correct directory resf #6986 --- test/php/library/Icinga/{ => Web}/Widget/DashboardTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/php/library/Icinga/{ => Web}/Widget/DashboardTest.php (99%) diff --git a/test/php/library/Icinga/Widget/DashboardTest.php b/test/php/library/Icinga/Web/Widget/DashboardTest.php similarity index 99% rename from test/php/library/Icinga/Widget/DashboardTest.php rename to test/php/library/Icinga/Web/Widget/DashboardTest.php index cdb4d8ff2..9114a8f90 100644 --- a/test/php/library/Icinga/Widget/DashboardTest.php +++ b/test/php/library/Icinga/Web/Widget/DashboardTest.php @@ -6,7 +6,7 @@ namespace Tests\Icinga\Web; // Necessary as some of these tests disable phpunit's preservation // of the global state (e.g. autoloaders are in the global state) -require_once realpath(dirname(__FILE__) . '/../../../bootstrap.php'); +require_once realpath(dirname(__FILE__) . '/../../../../bootstrap.php'); use Mockery; use Icinga\Application\Icinga; From b10b1ea03490961795394b2e90d031fab55c0602 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Tue, 2 Sep 2014 17:51:07 +0200 Subject: [PATCH 07/12] Add jQuery tooltips --- library/Icinga/Web/JavaScript.php | 6 +- library/Icinga/Web/StyleSheet.php | 2 + public/css/icinga/charts.less | 11 ++ public/js/icinga/events.js | 63 +++++++ public/js/icinga/ui.js | 4 +- public/js/icinga/utils.js | 34 ++++ public/js/vendor/jquery.tipsy.js | 262 +++++++++++++++++++++++++++ public/js/vendor/jquery.tipsy.min.js | 258 ++++++++++++++++++++++++++ 8 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 public/css/icinga/charts.less create mode 100644 public/js/vendor/jquery.tipsy.js create mode 100644 public/js/vendor/jquery.tipsy.min.js diff --git a/library/Icinga/Web/JavaScript.php b/library/Icinga/Web/JavaScript.php index 2f4ef0485..9eff9f5f0 100644 --- a/library/Icinga/Web/JavaScript.php +++ b/library/Icinga/Web/JavaScript.php @@ -24,12 +24,14 @@ class JavaScript protected static $vendorFiles = array( 'js/vendor/jquery-2.1.0', - 'js/vendor/jquery.sparkline' + 'js/vendor/jquery.sparkline', + 'js/vendor/jquery.tipsy' ); protected static $ie8VendorFiles = array( 'js/vendor/jquery-1.11.0', - 'js/vendor/jquery.sparkline' + 'js/vendor/jquery.sparkline', + 'js/vendor/jquery.tipsy' ); public static function listModuleFiles() diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index fedd8207a..cb1d7aacd 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -23,6 +23,8 @@ class StyleSheet 'css/icinga/monitoring-colors.less', 'css/icinga/selection-toolbar.less', 'css/icinga/login.less', + 'css/icinga/charts.less', + 'css/vendor/tipsy.less' ); public static function compileForPdf() diff --git a/public/css/icinga/charts.less b/public/css/icinga/charts.less new file mode 100644 index 000000000..6164921df --- /dev/null +++ b/public/css/icinga/charts.less @@ -0,0 +1,11 @@ +// {{{ICINGA_LICENSE_HEADER}}} +// {{{ICINGA_LICENSE_HEADER}}} + +/* Add hover effects to chart data */ +.chart-data:hover { + opacity: 0.85; +} + +.pie-data:hover { + opacity: 0.6; +} \ No newline at end of file diff --git a/public/js/icinga/events.js b/public/js/icinga/events.js index d04912d0f..e9ef7b2ea 100644 --- a/public/js/icinga/events.js +++ b/public/js/icinga/events.js @@ -109,6 +109,62 @@ if (searchField.length && searchField.val().length) { this.searchValue = searchField.val(); } + + $('[title]').each(function () { + var $el = $(this); + $el.attr('title', $el.attr('title-rich') || $el.attr('title')); + // $el.attr('title', null); + }); + + $('svg rect.chart-data[title]', el).tipsy({ gravity: 'e', html: true }); + $('.historycolorgrid a[title]', el).tipsy({ gravity: 's', offset: 2 }); + $('img.icon[title]', el).tipsy({ gravity: $.fn.tipsy.autoNS, offset: 2 }); + $('[title]', el).tipsy({ gravity: $.fn.tipsy.autoNS, delayIn: 500 }); + + // Rescue or remove all orphaned tooltips + $('.tipsy').each(function () { + function isElementInDOM(ele) { + while (ele = ele.parentNode) { + if (ele == document) return true; + } + return false; + }; + + var arrow = $('.tipsy-arrow', this)[0]; + if (!icinga.utils.elementsOverlap(arrow, $('#main')[0])) { + $(this).remove(); + return; + } + // all tooltips are direct children of the body + // so we need find out whether the tooltip belongs applied area, + // by checking if both areas overlap + if (!icinga.utils.elementsOverlap(arrow, el)) { + // tooltip does not belong to this area + return; + } + + var pointee = $.data(this, 'tipsy-pointee'); + if (!pointee || !isElementInDOM(pointee)) { + var orphan = this; + var oldTitle = $(this).find('.tipsy-inner').html(); + var migrated = false; + // try to find an element with the same title + $('[original-title="' + oldTitle + '"]').each(function() { + // get stored instance of Tipsy from newly created element + // point it to the orphaned tooltip + var tipsy = $.data(this, 'tipsy'); + tipsy.$tip = $(orphan); + $.data(this, 'tipsy', tipsy); + + // orphaned tooltip has the new element as pointee + $.data(orphan, 'tipsy-pointee', this); + migrated = true; + }); + if (!migrated) { + $(orphan).remove(); + } + } + }); }, /** @@ -162,6 +218,13 @@ // $(document).on('keyup', 'form.auto input', this.formChangeDelayed); // $(document).on('change', 'form.auto input', this.formChanged); // $(document).on('change', 'form.auto select', this.submitForm); + + $(document).on('mouseenter', '[title-original]', { gravity: 'n' }, function(event) { + icinga.ui.hoverTooltip (this, event.data); + }); + $(document).on('mouseleave', '[title-original]', {}, function() { + icinga.ui.unhoverTooltip (this); + }); }, menuTitleHovered: function (event) { diff --git a/public/js/icinga/ui.js b/public/js/icinga/ui.js index 8ff3733f0..d933844c9 100644 --- a/public/js/icinga/ui.js +++ b/public/js/icinga/ui.js @@ -10,6 +10,9 @@ 'use strict'; + // The currently hovered tooltip + var tooltip = null; + // Stores the icinga-data-url of the last focused table. var focusedTableDataUrl = null; @@ -767,7 +770,6 @@ this.debugTimer = null; this.timeCounterTimer = null; } - }; }(Icinga, jQuery)); diff --git a/public/js/icinga/utils.js b/public/js/icinga/utils.js index 4ce5e5917..1a6759d87 100644 --- a/public/js/icinga/utils.js +++ b/public/js/icinga/utils.js @@ -200,6 +200,40 @@ return params; }, + /** + * Check whether two HTMLElements overlap + * + * @param a {HTMLElement} + * @param b {HTMLElement} + * + * @returns {Boolean} whether elements overlap, will return false when one + * element is not in the DOM + */ + elementsOverlap: function(a, b) + { + // a bounds + var aoff = $(a).offset(); + if (!aoff) { + return false; + } + var at = aoff.top; + var ah = a.offsetHeight; + var al = aoff.left; + var aw = a.offsetWidth; + + // b bounds + var boff = $(b).offset(); + if (!boff) { + return false; + } + var bt = boff.top; + var bh = b.offsetHeight; + var bl = boff.left; + var bw = b.offsetWidth; + + return !(at > (bt + bh) || bt > (at + ah)) && !(bl > (al + aw) || al > (bl + bw)); + }, + /** * Cleanup */ diff --git a/public/js/vendor/jquery.tipsy.js b/public/js/vendor/jquery.tipsy.js new file mode 100644 index 000000000..a17d7eeca --- /dev/null +++ b/public/js/vendor/jquery.tipsy.js @@ -0,0 +1,262 @@ +// tipsy, facebook style tooltips for jquery +// version 1.0.0a +// (c) 2008-2010 jason frame [jason@onehackoranother.com] +// released under the MIT license + +(function($) { + + function maybeCall(thing, ctx) { + return (typeof thing == 'function') ? (thing.call(ctx)) : thing; + }; + + function isElementInDOM(ele) { + while (ele = ele.parentNode) { + if (ele == document) return true; + } + return false; + }; + + function Tipsy(element, options) { + this.$element = $(element); + this.options = options; + this.enabled = true; + this.fixTitle(); + }; + + Tipsy.prototype = { + show: function() { + var title = this.getTitle(); + if (title && this.enabled) { + var $tip = this.tip(); + + $tip.find('.tipsy-inner')[this.options.html ? 'html' : 'text'](title); + $tip[0].className = 'tipsy'; // reset classname in case of dynamic gravity + $tip.remove().css({top: 0, left: 0, visibility: 'hidden', display: 'block'}).prependTo(document.body); + + var pos = $.extend({}, this.$element.offset(), { + width: this.$element[0].offsetWidth, + height: this.$element[0].offsetHeight + }); + + var actualWidth = $tip[0].offsetWidth, + actualHeight = $tip[0].offsetHeight, + gravity = maybeCall(this.options.gravity, this.$element[0]); + + var tp; + switch (gravity.charAt(0)) { + case 'n': + tp = {top: pos.top + pos.height + this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2}; + break; + case 's': + tp = {top: pos.top - actualHeight - this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2}; + break; + case 'e': + tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth - this.options.offset}; + break; + case 'w': + tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width + this.options.offset}; + break; + } + + if (gravity.length == 2) { + if (gravity.charAt(1) == 'w') { + tp.left = pos.left + pos.width / 2 - 15; + } else { + tp.left = pos.left + pos.width / 2 - actualWidth + 15; + } + } + + $tip.css(tp).addClass('tipsy-' + gravity); + $tip.find('.tipsy-arrow')[0].className = 'tipsy-arrow tipsy-arrow-' + gravity.charAt(0); + if (this.options.className) { + $tip.addClass(maybeCall(this.options.className, this.$element[0])); + } + + if (this.options.fade) { + $tip.stop().css({opacity: 0, display: 'block', visibility: 'visible'}).animate({opacity: this.options.opacity}); + } else { + $tip.css({visibility: 'visible', opacity: this.options.opacity}); + } + } + }, + + hide: function() { + if (this.options.fade) { + this.tip().stop().fadeOut(function() { $(this).remove(); }); + } else { + this.tip().remove(); + } + }, + + fixTitle: function() { + var $e = this.$element; + if ($e.attr('title') || typeof($e.attr('original-title')) != 'string') { + $e.attr('original-title', $e.attr('title') || '').removeAttr('title'); + } + }, + + getTitle: function() { + var title, $e = this.$element, o = this.options; + this.fixTitle(); + var title, o = this.options; + if (typeof o.title == 'string') { + title = $e.attr(o.title == 'title' ? 'original-title' : o.title); + } else if (typeof o.title == 'function') { + title = o.title.call($e[0]); + } + title = ('' + title).replace(/(^\s*|\s*$)/, ""); + return title || o.fallback; + }, + + tip: function() { + if (!this.$tip) { + this.$tip = $('
    ').html('
    '); + this.$tip.data('tipsy-pointee', this.$element[0]); + } + return this.$tip; + }, + + validate: function() { + if (!this.$element[0].parentNode) { + this.hide(); + this.$element = null; + this.options = null; + } + }, + + enable: function() { this.enabled = true; }, + disable: function() { this.enabled = false; }, + toggleEnabled: function() { this.enabled = !this.enabled; } + }; + + $.fn.tipsy = function(options) { + + if (options === true) { + return this.data('tipsy'); + } else if (typeof options == 'string') { + var tipsy = this.data('tipsy'); + if (tipsy) tipsy[options](); + return this; + } + + options = $.extend({}, $.fn.tipsy.defaults, options); + + function get(ele) { + var tipsy = $.data(ele, 'tipsy'); + if (!tipsy) { + tipsy = new Tipsy(ele, $.fn.tipsy.elementOptions(ele, options)); + $.data(ele, 'tipsy', tipsy); + } + return tipsy; + } + + function enter() { + var tipsy = get(this); + var ele = this; + tipsy.hoverState = 'in'; + if (options.delayIn == 0) { + tipsy.show(); + } else { + tipsy.fixTitle(); + setTimeout(function() { + if (tipsy.hoverState == 'in' && ele && isElementInDOM(ele)) + tipsy.show(); + }, options.delayIn); + } + }; + + function leave() { + var tipsy = get(this); + tipsy.hoverState = 'out'; + if (options.delayOut == 0) { + tipsy.hide(); + } else { + setTimeout(function() { if (tipsy.hoverState == 'out') tipsy.hide(); }, options.delayOut); + } + }; + + if (!options.live) this.each(function() { get(this); }); + + if (options.trigger != 'manual') { + var binder = options.live ? 'live' : 'bind', + eventIn = options.trigger == 'hover' ? 'mouseenter' : 'focus', + eventOut = options.trigger == 'hover' ? 'mouseleave' : 'blur'; + this[binder](eventIn, enter)[binder](eventOut, leave); + } + + return this; + + }; + + $.fn.tipsy.defaults = { + className: null, + delayIn: 0, + delayOut: 0, + fade: false, + fallback: '', + gravity: 'n', + html: false, + live: false, + offset: 0, + opacity: 0.8, + title: 'title', + trigger: 'hover' + }; + + $.fn.tipsy.revalidate = function() { + $('.tipsy').each(function() { + var pointee = $.data(this, 'tipsy-pointee'); + if (!pointee || !isElementInDOM(pointee)) { + $(this).remove(); + } + }); + }; + + // Overwrite this method to provide options on a per-element basis. + // For example, you could store the gravity in a 'tipsy-gravity' attribute: + // return $.extend({}, options, {gravity: $(ele).attr('tipsy-gravity') || 'n' }); + // (remember - do not modify 'options' in place!) + $.fn.tipsy.elementOptions = function(ele, options) { + return $.metadata ? $.extend({}, options, $(ele).metadata()) : options; + }; + + $.fn.tipsy.autoNS = function() { + return $(this).offset().top > ($(document).scrollTop() + $(window).height() / 2) ? 's' : 'n'; + }; + + $.fn.tipsy.autoWE = function() { + return $(this).offset().left > ($(document).scrollLeft() + $(window).width() / 2) ? 'e' : 'w'; + }; + + /** + * yields a closure of the supplied parameters, producing a function that takes + * no arguments and is suitable for use as an autogravity function like so: + * + * @param margin (int) - distance from the viewable region edge that an + * element should be before setting its tooltip's gravity to be away + * from that edge. + * @param prefer (string, e.g. 'n', 'sw', 'w') - the direction to prefer + * if there are no viewable region edges effecting the tooltip's + * gravity. It will try to vary from this minimally, for example, + * if 'sw' is preferred and an element is near the right viewable + * region edge, but not the top edge, it will set the gravity for + * that element's tooltip to be 'se', preserving the southern + * component. + */ + $.fn.tipsy.autoBounds = function(margin, prefer) { + return function() { + var dir = {ns: prefer[0], ew: (prefer.length > 1 ? prefer[1] : false)}, + boundTop = $(document).scrollTop() + margin, + boundLeft = $(document).scrollLeft() + margin, + $this = $(this); + + if ($this.offset().top < boundTop) dir.ns = 'n'; + if ($this.offset().left < boundLeft) dir.ew = 'w'; + if ($(window).width() + $(document).scrollLeft() - $this.offset().left < margin) dir.ew = 'e'; + if ($(window).height() + $(document).scrollTop() - $this.offset().top < margin) dir.ns = 's'; + + return dir.ns + (dir.ew ? dir.ew : ''); + } + }; + +})(jQuery); diff --git a/public/js/vendor/jquery.tipsy.min.js b/public/js/vendor/jquery.tipsy.min.js new file mode 100644 index 000000000..2bf66bd16 --- /dev/null +++ b/public/js/vendor/jquery.tipsy.min.js @@ -0,0 +1,258 @@ +// tipsy, facebook style tooltips for jquery +// version 1.0.0a +// (c) 2008-2010 jason frame [jason@onehackoranother.com] +// released under the MIT license + +(function($) { + + function maybeCall(thing, ctx) { + return (typeof thing == 'function') ? (thing.call(ctx)) : thing; + }; + + function isElementInDOM(ele) { + while (ele = ele.parentNode) { + if (ele == document) return true; + } + return false; + }; + + function Tipsy(element, options) { + this.$element = $(element); + this.options = options; + this.enabled = true; + this.fixTitle(); + }; + + Tipsy.prototype = { + show: function() { + var title = this.getTitle(); + if (title && this.enabled) { + var $tip = this.tip(); + + $tip.find('.tipsy-inner')[this.options.html ? 'html' : 'text'](title); + $tip[0].className = 'tipsy'; // reset classname in case of dynamic gravity + $tip.remove().css({top: 0, left: 0, visibility: 'hidden', display: 'block'}).prependTo(document.body); + + var pos = $.extend({}, this.$element.offset(), { + width: this.$element[0].offsetWidth, + height: this.$element[0].offsetHeight + }); + + var actualWidth = $tip[0].offsetWidth, + actualHeight = $tip[0].offsetHeight, + gravity = maybeCall(this.options.gravity, this.$element[0]); + + var tp; + switch (gravity.charAt(0)) { + case 'n': + tp = {top: pos.top + pos.height + this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2}; + break; + case 's': + tp = {top: pos.top - actualHeight - this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2}; + break; + case 'e': + tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth - this.options.offset}; + break; + case 'w': + tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width + this.options.offset}; + break; + } + + if (gravity.length == 2) { + if (gravity.charAt(1) == 'w') { + tp.left = pos.left + pos.width / 2 - 15; + } else { + tp.left = pos.left + pos.width / 2 - actualWidth + 15; + } + } + + $tip.css(tp).addClass('tipsy-' + gravity); + $tip.find('.tipsy-arrow')[0].className = 'tipsy-arrow tipsy-arrow-' + gravity.charAt(0); + if (this.options.className) { + $tip.addClass(maybeCall(this.options.className, this.$element[0])); + } + + if (this.options.fade) { + $tip.stop().css({opacity: 0, display: 'block', visibility: 'visible'}).animate({opacity: this.options.opacity}); + } else { + $tip.css({visibility: 'visible', opacity: this.options.opacity}); + } + } + }, + + hide: function() { + if (this.options.fade) { + this.tip().stop().fadeOut(function() { $(this).remove(); }); + } else { + this.tip().remove(); + } + }, + + fixTitle: function() { + var $e = this.$element; + if ($e.attr('title') || typeof($e.attr('original-title')) != 'string') { + $e.attr('original-title', $e.attr('title') || '').removeAttr('title'); + } + }, + + getTitle: function() { + var title, $e = this.$element, o = this.options; + this.fixTitle(); + var title, o = this.options; + if (typeof o.title == 'string') { + title = $e.attr(o.title == 'title' ? 'original-title' : o.title); + } else if (typeof o.title == 'function') { + title = o.title.call($e[0]); + } + title = ('' + title).replace(/(^\s*|\s*$)/, ""); + return title || o.fallback; + }, + + tip: function() { + if (!this.$tip) { + this.$tip = $('
    ').html('
    '); + this.$tip.data('tipsy-pointee', this.$element[0]); + } + return this.$tip; + }, + + validate: function() { + if (!this.$element[0].parentNode) { + this.hide(); + this.$element = null; + this.options = null; + } + }, + + enable: function() { this.enabled = true; }, + disable: function() { this.enabled = false; }, + toggleEnabled: function() { this.enabled = !this.enabled; } + }; + + $.fn.tipsy = function(options) { + + if (options === true) { + return this.data('tipsy'); + } else if (typeof options == 'string') { + var tipsy = this.data('tipsy'); + if (tipsy) tipsy[options](); + return this; + } + + options = $.extend({}, $.fn.tipsy.defaults, options); + + function get(ele) { + var tipsy = $.data(ele, 'tipsy'); + if (!tipsy) { + tipsy = new Tipsy(ele, $.fn.tipsy.elementOptions(ele, options)); + $.data(ele, 'tipsy', tipsy); + } + return tipsy; + } + + function enter() { + var tipsy = get(this); + tipsy.hoverState = 'in'; + if (options.delayIn == 0) { + tipsy.show(); + } else { + tipsy.fixTitle(); + setTimeout(function() { if (tipsy.hoverState == 'in') tipsy.show(); }, options.delayIn); + } + }; + + function leave() { + var tipsy = get(this); + tipsy.hoverState = 'out'; + if (options.delayOut == 0) { + tipsy.hide(); + } else { + setTimeout(function() { if (tipsy.hoverState == 'out') tipsy.hide(); }, options.delayOut); + } + }; + + if (!options.live) this.each(function() { get(this); }); + + if (options.trigger != 'manual') { + var binder = options.live ? 'live' : 'bind', + eventIn = options.trigger == 'hover' ? 'mouseenter' : 'focus', + eventOut = options.trigger == 'hover' ? 'mouseleave' : 'blur'; + this[binder](eventIn, enter)[binder](eventOut, leave); + } + + return this; + + }; + + $.fn.tipsy.defaults = { + className: null, + delayIn: 0, + delayOut: 0, + fade: false, + fallback: '', + gravity: 'n', + html: false, + live: false, + offset: 0, + opacity: 0.8, + title: 'title', + trigger: 'hover' + }; + + $.fn.tipsy.revalidate = function() { + $('.tipsy').each(function() { + var pointee = $.data(this, 'tipsy-pointee'); + if (!pointee || !isElementInDOM(pointee)) { + $(this).remove(); + } + }); + }; + + // Overwrite this method to provide options on a per-element basis. + // For example, you could store the gravity in a 'tipsy-gravity' attribute: + // return $.extend({}, options, {gravity: $(ele).attr('tipsy-gravity') || 'n' }); + // (remember - do not modify 'options' in place!) + $.fn.tipsy.elementOptions = function(ele, options) { + return $.metadata ? $.extend({}, options, $(ele).metadata()) : options; + }; + + $.fn.tipsy.autoNS = function() { + return $(this).offset().top > ($(document).scrollTop() + $(window).height() / 2) ? 's' : 'n'; + }; + + $.fn.tipsy.autoWE = function() { + return $(this).offset().left > ($(document).scrollLeft() + $(window).width() / 2) ? 'e' : 'w'; + }; + + /** + * yields a closure of the supplied parameters, producing a function that takes + * no arguments and is suitable for use as an autogravity function like so: + * + * @param margin (int) - distance from the viewable region edge that an + * element should be before setting its tooltip's gravity to be away + * from that edge. + * @param prefer (string, e.g. 'n', 'sw', 'w') - the direction to prefer + * if there are no viewable region edges effecting the tooltip's + * gravity. It will try to vary from this minimally, for example, + * if 'sw' is preferred and an element is near the right viewable + * region edge, but not the top edge, it will set the gravity for + * that element's tooltip to be 'se', preserving the southern + * component. + */ + $.fn.tipsy.autoBounds = function(margin, prefer) { + return function() { + var dir = {ns: prefer[0], ew: (prefer.length > 1 ? prefer[1] : false)}, + boundTop = $(document).scrollTop() + margin, + boundLeft = $(document).scrollLeft() + margin, + $this = $(this); + + if ($this.offset().top < boundTop) dir.ns = 'n'; + if ($this.offset().left < boundLeft) dir.ew = 'w'; + if ($(window).width() + $(document).scrollLeft() - $this.offset().left < margin) dir.ew = 'e'; + if ($(window).height() + $(document).scrollTop() - $this.offset().top < margin) dir.ns = 's'; + + return dir.ns + (dir.ew ? dir.ew : ''); + } + }; + +})(jQuery); From 37b168995b0cce3ffd04cfe80c415c434c0fbf34 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Wed, 3 Sep 2014 17:01:07 +0200 Subject: [PATCH 08/12] Rename tipsy stylesheet file type to css and add license --- library/Icinga/Web/StyleSheet.php | 2 +- public/css/vendor/tipsy.css | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 public/css/vendor/tipsy.css diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index cb1d7aacd..0a2a33bc9 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -24,7 +24,7 @@ class StyleSheet 'css/icinga/selection-toolbar.less', 'css/icinga/login.less', 'css/icinga/charts.less', - 'css/vendor/tipsy.less' + 'css/vendor/tipsy.css' ); public static function compileForPdf() diff --git a/public/css/vendor/tipsy.css b/public/css/vendor/tipsy.css new file mode 100644 index 000000000..1da9aa9ac --- /dev/null +++ b/public/css/vendor/tipsy.css @@ -0,0 +1,30 @@ +/* tipsy, facebook style tooltips for jquery + version 1.0.0a + (c) 2008-2010 jason frame [jason@onehackoranother.com] + released under the MIT license */ + +.tipsy { font-size: 14px; position: absolute; padding: 5px; z-index: 100000; } + .tipsy-inner { background-color: #000; color: #FFF; max-width: 200px; padding: 5px 8px 4px 8px; text-align: center; } + + /* Rounded corners */ + .tipsy-inner { border-radius: 3px; -moz-border-radius: 3px; -webkit-border-radius: 3px; } + + /* Uncomment for shadow */ + /*.tipsy-inner { box-shadow: 0 0 5px #000000; -webkit-box-shadow: 0 0 5px #000000; -moz-box-shadow: 0 0 5px #000000; }*/ + + .tipsy-arrow { position: absolute; width: 0; height: 0; line-height: 0; border: 5px dashed #000; } + + /* Rules to colour arrows */ + .tipsy-arrow-n { border-bottom-color: #000; } + .tipsy-arrow-s { border-top-color: #000; } + .tipsy-arrow-e { border-left-color: #000; } + .tipsy-arrow-w { border-right-color: #000; } + + .tipsy-n .tipsy-arrow { top: 0px; left: 50%; margin-left: -5px; border-bottom-style: solid; border-top: none; border-left-color: transparent; border-right-color: transparent; } + .tipsy-nw .tipsy-arrow { top: 0; left: 10px; border-bottom-style: solid; border-top: none; border-left-color: transparent; border-right-color: transparent;} + .tipsy-ne .tipsy-arrow { top: 0; right: 10px; border-bottom-style: solid; border-top: none; border-left-color: transparent; border-right-color: transparent;} + .tipsy-s .tipsy-arrow { bottom: 0; left: 50%; margin-left: -5px; border-top-style: solid; border-bottom: none; border-left-color: transparent; border-right-color: transparent; } + .tipsy-sw .tipsy-arrow { bottom: 0; left: 10px; border-top-style: solid; border-bottom: none; border-left-color: transparent; border-right-color: transparent; } + .tipsy-se .tipsy-arrow { bottom: 0; right: 10px; border-top-style: solid; border-bottom: none; border-left-color: transparent; border-right-color: transparent; } + .tipsy-e .tipsy-arrow { right: 0; top: 50%; margin-top: -5px; border-left-style: solid; border-right: none; border-top-color: transparent; border-bottom-color: transparent; } + .tipsy-w .tipsy-arrow { left: 0; top: 50%; margin-top: -5px; border-right-style: solid; border-left: none; border-top-color: transparent; border-bottom-color: transparent; } From 593134203c16cbfbe31584929931ed4c61c1e19d Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Wed, 3 Sep 2014 17:02:01 +0200 Subject: [PATCH 09/12] Add minified version of tipsy sources --- public/js/vendor/jquery.tipsy.min.js | 260 +-------------------------- 1 file changed, 2 insertions(+), 258 deletions(-) diff --git a/public/js/vendor/jquery.tipsy.min.js b/public/js/vendor/jquery.tipsy.min.js index 2bf66bd16..59f6c2375 100644 --- a/public/js/vendor/jquery.tipsy.min.js +++ b/public/js/vendor/jquery.tipsy.min.js @@ -1,258 +1,2 @@ -// tipsy, facebook style tooltips for jquery -// version 1.0.0a -// (c) 2008-2010 jason frame [jason@onehackoranother.com] -// released under the MIT license - -(function($) { - - function maybeCall(thing, ctx) { - return (typeof thing == 'function') ? (thing.call(ctx)) : thing; - }; - - function isElementInDOM(ele) { - while (ele = ele.parentNode) { - if (ele == document) return true; - } - return false; - }; - - function Tipsy(element, options) { - this.$element = $(element); - this.options = options; - this.enabled = true; - this.fixTitle(); - }; - - Tipsy.prototype = { - show: function() { - var title = this.getTitle(); - if (title && this.enabled) { - var $tip = this.tip(); - - $tip.find('.tipsy-inner')[this.options.html ? 'html' : 'text'](title); - $tip[0].className = 'tipsy'; // reset classname in case of dynamic gravity - $tip.remove().css({top: 0, left: 0, visibility: 'hidden', display: 'block'}).prependTo(document.body); - - var pos = $.extend({}, this.$element.offset(), { - width: this.$element[0].offsetWidth, - height: this.$element[0].offsetHeight - }); - - var actualWidth = $tip[0].offsetWidth, - actualHeight = $tip[0].offsetHeight, - gravity = maybeCall(this.options.gravity, this.$element[0]); - - var tp; - switch (gravity.charAt(0)) { - case 'n': - tp = {top: pos.top + pos.height + this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2}; - break; - case 's': - tp = {top: pos.top - actualHeight - this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2}; - break; - case 'e': - tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth - this.options.offset}; - break; - case 'w': - tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width + this.options.offset}; - break; - } - - if (gravity.length == 2) { - if (gravity.charAt(1) == 'w') { - tp.left = pos.left + pos.width / 2 - 15; - } else { - tp.left = pos.left + pos.width / 2 - actualWidth + 15; - } - } - - $tip.css(tp).addClass('tipsy-' + gravity); - $tip.find('.tipsy-arrow')[0].className = 'tipsy-arrow tipsy-arrow-' + gravity.charAt(0); - if (this.options.className) { - $tip.addClass(maybeCall(this.options.className, this.$element[0])); - } - - if (this.options.fade) { - $tip.stop().css({opacity: 0, display: 'block', visibility: 'visible'}).animate({opacity: this.options.opacity}); - } else { - $tip.css({visibility: 'visible', opacity: this.options.opacity}); - } - } - }, - - hide: function() { - if (this.options.fade) { - this.tip().stop().fadeOut(function() { $(this).remove(); }); - } else { - this.tip().remove(); - } - }, - - fixTitle: function() { - var $e = this.$element; - if ($e.attr('title') || typeof($e.attr('original-title')) != 'string') { - $e.attr('original-title', $e.attr('title') || '').removeAttr('title'); - } - }, - - getTitle: function() { - var title, $e = this.$element, o = this.options; - this.fixTitle(); - var title, o = this.options; - if (typeof o.title == 'string') { - title = $e.attr(o.title == 'title' ? 'original-title' : o.title); - } else if (typeof o.title == 'function') { - title = o.title.call($e[0]); - } - title = ('' + title).replace(/(^\s*|\s*$)/, ""); - return title || o.fallback; - }, - - tip: function() { - if (!this.$tip) { - this.$tip = $('
    ').html('
    '); - this.$tip.data('tipsy-pointee', this.$element[0]); - } - return this.$tip; - }, - - validate: function() { - if (!this.$element[0].parentNode) { - this.hide(); - this.$element = null; - this.options = null; - } - }, - - enable: function() { this.enabled = true; }, - disable: function() { this.enabled = false; }, - toggleEnabled: function() { this.enabled = !this.enabled; } - }; - - $.fn.tipsy = function(options) { - - if (options === true) { - return this.data('tipsy'); - } else if (typeof options == 'string') { - var tipsy = this.data('tipsy'); - if (tipsy) tipsy[options](); - return this; - } - - options = $.extend({}, $.fn.tipsy.defaults, options); - - function get(ele) { - var tipsy = $.data(ele, 'tipsy'); - if (!tipsy) { - tipsy = new Tipsy(ele, $.fn.tipsy.elementOptions(ele, options)); - $.data(ele, 'tipsy', tipsy); - } - return tipsy; - } - - function enter() { - var tipsy = get(this); - tipsy.hoverState = 'in'; - if (options.delayIn == 0) { - tipsy.show(); - } else { - tipsy.fixTitle(); - setTimeout(function() { if (tipsy.hoverState == 'in') tipsy.show(); }, options.delayIn); - } - }; - - function leave() { - var tipsy = get(this); - tipsy.hoverState = 'out'; - if (options.delayOut == 0) { - tipsy.hide(); - } else { - setTimeout(function() { if (tipsy.hoverState == 'out') tipsy.hide(); }, options.delayOut); - } - }; - - if (!options.live) this.each(function() { get(this); }); - - if (options.trigger != 'manual') { - var binder = options.live ? 'live' : 'bind', - eventIn = options.trigger == 'hover' ? 'mouseenter' : 'focus', - eventOut = options.trigger == 'hover' ? 'mouseleave' : 'blur'; - this[binder](eventIn, enter)[binder](eventOut, leave); - } - - return this; - - }; - - $.fn.tipsy.defaults = { - className: null, - delayIn: 0, - delayOut: 0, - fade: false, - fallback: '', - gravity: 'n', - html: false, - live: false, - offset: 0, - opacity: 0.8, - title: 'title', - trigger: 'hover' - }; - - $.fn.tipsy.revalidate = function() { - $('.tipsy').each(function() { - var pointee = $.data(this, 'tipsy-pointee'); - if (!pointee || !isElementInDOM(pointee)) { - $(this).remove(); - } - }); - }; - - // Overwrite this method to provide options on a per-element basis. - // For example, you could store the gravity in a 'tipsy-gravity' attribute: - // return $.extend({}, options, {gravity: $(ele).attr('tipsy-gravity') || 'n' }); - // (remember - do not modify 'options' in place!) - $.fn.tipsy.elementOptions = function(ele, options) { - return $.metadata ? $.extend({}, options, $(ele).metadata()) : options; - }; - - $.fn.tipsy.autoNS = function() { - return $(this).offset().top > ($(document).scrollTop() + $(window).height() / 2) ? 's' : 'n'; - }; - - $.fn.tipsy.autoWE = function() { - return $(this).offset().left > ($(document).scrollLeft() + $(window).width() / 2) ? 'e' : 'w'; - }; - - /** - * yields a closure of the supplied parameters, producing a function that takes - * no arguments and is suitable for use as an autogravity function like so: - * - * @param margin (int) - distance from the viewable region edge that an - * element should be before setting its tooltip's gravity to be away - * from that edge. - * @param prefer (string, e.g. 'n', 'sw', 'w') - the direction to prefer - * if there are no viewable region edges effecting the tooltip's - * gravity. It will try to vary from this minimally, for example, - * if 'sw' is preferred and an element is near the right viewable - * region edge, but not the top edge, it will set the gravity for - * that element's tooltip to be 'se', preserving the southern - * component. - */ - $.fn.tipsy.autoBounds = function(margin, prefer) { - return function() { - var dir = {ns: prefer[0], ew: (prefer.length > 1 ? prefer[1] : false)}, - boundTop = $(document).scrollTop() + margin, - boundLeft = $(document).scrollLeft() + margin, - $this = $(this); - - if ($this.offset().top < boundTop) dir.ns = 'n'; - if ($this.offset().left < boundLeft) dir.ew = 'w'; - if ($(window).width() + $(document).scrollLeft() - $this.offset().left < margin) dir.ew = 'e'; - if ($(window).height() + $(document).scrollTop() - $this.offset().top < margin) dir.ns = 's'; - - return dir.ns + (dir.ew ? dir.ew : ''); - } - }; - -})(jQuery); +/* tipsy, facebook style tooltips for jquery / version 1.0.0a / (c) 2008-2010 jason frame [jason@onehackoranother.com] / released under the MIT license / minified with uglifyjs */ +(function($){function maybeCall(thing,ctx){return typeof thing=="function"?thing.call(ctx):thing}function isElementInDOM(ele){while(ele=ele.parentNode){if(ele==document)return true}return false}function Tipsy(element,options){this.$element=$(element);this.options=options;this.enabled=true;this.fixTitle()}Tipsy.prototype={show:function(){var title=this.getTitle();if(title&&this.enabled){var $tip=this.tip();$tip.find(".tipsy-inner")[this.options.html?"html":"text"](title);$tip[0].className="tipsy";$tip.remove().css({top:0,left:0,visibility:"hidden",display:"block"}).prependTo(document.body);var pos=$.extend({},this.$element.offset(),{width:this.$element[0].offsetWidth,height:this.$element[0].offsetHeight});var actualWidth=$tip[0].offsetWidth,actualHeight=$tip[0].offsetHeight,gravity=maybeCall(this.options.gravity,this.$element[0]);var tp;switch(gravity.charAt(0)){case"n":tp={top:pos.top+pos.height+this.options.offset,left:pos.left+pos.width/2-actualWidth/2};break;case"s":tp={top:pos.top-actualHeight-this.options.offset,left:pos.left+pos.width/2-actualWidth/2};break;case"e":tp={top:pos.top+pos.height/2-actualHeight/2,left:pos.left-actualWidth-this.options.offset};break;case"w":tp={top:pos.top+pos.height/2-actualHeight/2,left:pos.left+pos.width+this.options.offset};break}if(gravity.length==2){if(gravity.charAt(1)=="w"){tp.left=pos.left+pos.width/2-15}else{tp.left=pos.left+pos.width/2-actualWidth+15}}$tip.css(tp).addClass("tipsy-"+gravity);$tip.find(".tipsy-arrow")[0].className="tipsy-arrow tipsy-arrow-"+gravity.charAt(0);if(this.options.className){$tip.addClass(maybeCall(this.options.className,this.$element[0]))}if(this.options.fade){$tip.stop().css({opacity:0,display:"block",visibility:"visible"}).animate({opacity:this.options.opacity})}else{$tip.css({visibility:"visible",opacity:this.options.opacity})}}},hide:function(){if(this.options.fade){this.tip().stop().fadeOut(function(){$(this).remove()})}else{this.tip().remove()}},fixTitle:function(){var $e=this.$element;if($e.attr("title")||typeof $e.attr("original-title")!="string"){$e.attr("original-title",$e.attr("title")||"").removeAttr("title")}},getTitle:function(){var title,$e=this.$element,o=this.options;this.fixTitle();var title,o=this.options;if(typeof o.title=="string"){title=$e.attr(o.title=="title"?"original-title":o.title)}else if(typeof o.title=="function"){title=o.title.call($e[0])}title=(""+title).replace(/(^\s*|\s*$)/,"");return title||o.fallback},tip:function(){if(!this.$tip){this.$tip=$('
    ').html('
    ');this.$tip.data("tipsy-pointee",this.$element[0])}return this.$tip},validate:function(){if(!this.$element[0].parentNode){this.hide();this.$element=null;this.options=null}},enable:function(){this.enabled=true},disable:function(){this.enabled=false},toggleEnabled:function(){this.enabled=!this.enabled}};$.fn.tipsy=function(options){if(options===true){return this.data("tipsy")}else if(typeof options=="string"){var tipsy=this.data("tipsy");if(tipsy)tipsy[options]();return this}options=$.extend({},$.fn.tipsy.defaults,options);function get(ele){var tipsy=$.data(ele,"tipsy");if(!tipsy){tipsy=new Tipsy(ele,$.fn.tipsy.elementOptions(ele,options));$.data(ele,"tipsy",tipsy)}return tipsy}function enter(){var tipsy=get(this);var ele=this;tipsy.hoverState="in";if(options.delayIn==0){tipsy.show()}else{tipsy.fixTitle();setTimeout(function(){if(tipsy.hoverState=="in"&&ele&&isElementInDOM(ele))tipsy.show()},options.delayIn)}}function leave(){var tipsy=get(this);tipsy.hoverState="out";if(options.delayOut==0){tipsy.hide()}else{setTimeout(function(){if(tipsy.hoverState=="out")tipsy.hide()},options.delayOut)}}if(!options.live)this.each(function(){get(this)});if(options.trigger!="manual"){var binder=options.live?"live":"bind",eventIn=options.trigger=="hover"?"mouseenter":"focus",eventOut=options.trigger=="hover"?"mouseleave":"blur";this[binder](eventIn,enter)[binder](eventOut,leave)}return this};$.fn.tipsy.defaults={className:null,delayIn:0,delayOut:0,fade:false,fallback:"",gravity:"n",html:false,live:false,offset:0,opacity:.8,title:"title",trigger:"hover"};$.fn.tipsy.revalidate=function(){$(".tipsy").each(function(){var pointee=$.data(this,"tipsy-pointee");if(!pointee||!isElementInDOM(pointee)){$(this).remove()}})};$.fn.tipsy.elementOptions=function(ele,options){return $.metadata?$.extend({},options,$(ele).metadata()):options};$.fn.tipsy.autoNS=function(){return $(this).offset().top>$(document).scrollTop()+$(window).height()/2?"s":"n"};$.fn.tipsy.autoWE=function(){return $(this).offset().left>$(document).scrollLeft()+$(window).width()/2?"e":"w"};$.fn.tipsy.autoBounds=function(margin,prefer){return function(){var dir={ns:prefer[0],ew:prefer.length>1?prefer[1]:false},boundTop=$(document).scrollTop()+margin,boundLeft=$(document).scrollLeft()+margin,$this=$(this);if($this.offset().top Date: Wed, 3 Sep 2014 17:03:04 +0200 Subject: [PATCH 10/12] Add information about the source and installation of all needed jquery-tipsy files --- public/js/vendor/SOURCE.jquery.tipsy | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 public/js/vendor/SOURCE.jquery.tipsy diff --git a/public/js/vendor/SOURCE.jquery.tipsy b/public/js/vendor/SOURCE.jquery.tipsy new file mode 100644 index 000000000..5df83c9c9 --- /dev/null +++ b/public/js/vendor/SOURCE.jquery.tipsy @@ -0,0 +1,33 @@ +jquery.tipsy.js SOURCE +====================== + +This file contains information about how to acquire and install the source files for jquery.tipsy + + +# version + + 1.0.0a + + +# license + + MIT license + + +# used files + + src/javascript/tipsy.css + src/javascript/jquery.tipsy.js + + +# source + + https://github.com/jaz303/tipsy.git + + +# installation + + + mv src/javascript/tipsy.css ICINGAWEB/public/css/vendor/tipsy.css + mv src/javascript/jquery.tipsy.js ICINGAWEB/public/js/vendor/jquery.tipsy.js + uglifyjs src/javascript/jquery.tipsy.js ICINGAWEB/public/js/vendor/jquery.tipsy.min.js \ No newline at end of file From 510e38335e09a6b3f7fe1507af7d3322ffc4b655 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Wed, 3 Sep 2014 17:42:31 +0200 Subject: [PATCH 11/12] Remove useless tooltip from host and service list remove titles from time spans on all td.state, since the formatted time is already displayed in the inner div --- public/js/icinga/events.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/js/icinga/events.js b/public/js/icinga/events.js index e9ef7b2ea..dfeec81cf 100644 --- a/public/js/icinga/events.js +++ b/public/js/icinga/events.js @@ -53,6 +53,8 @@ } }); + $('td.state span.timesince').attr('title', null); + var moduleName = el.data('icingaModule'); if (moduleName) { if (icinga.hasModule(moduleName)) { From 8fdf20fbf25b7877da0f188434e7e90f362b6fbf Mon Sep 17 00:00:00 2001 From: Thomas Gelf Date: Wed, 3 Sep 2014 18:55:20 +0200 Subject: [PATCH 12/12] Groupsummary: remove invalid sort column discovery This didn't result in a loop 'til now as it wasn't even used. The problem here is that the same View is used for tow differend things, this makes no sense. In the meantime removing that code fixes related problems. --- .../application/controllers/ChartController.php | 4 ++-- .../library/Monitoring/DataView/Groupsummary.php | 16 ---------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/modules/monitoring/application/controllers/ChartController.php b/modules/monitoring/application/controllers/ChartController.php index af6240259..8cf6f8bd4 100644 --- a/modules/monitoring/application/controllers/ChartController.php +++ b/modules/monitoring/application/controllers/ChartController.php @@ -99,7 +99,7 @@ class Monitoring_ChartController extends Controller 'services_warning_unhandled', 'services_pending' ) - )->getQuery()->fetchAll(); + )->order('hostgroup')->getQuery()->fetchAll(); $this->view->height = intval($this->getParam('height', 500)); $this->view->width = intval($this->getParam('width', 500)); if (count($query) === 1) { @@ -124,7 +124,7 @@ class Monitoring_ChartController extends Controller 'services_warning_unhandled', 'services_pending' ) - )->getQuery()->fetchAll(); + )->order('servicegroup')->getQuery()->fetchAll(); $this->view->height = intval($this->getParam('height', 500)); $this->view->width = intval($this->getParam('width', 500)); diff --git a/modules/monitoring/library/Monitoring/DataView/Groupsummary.php b/modules/monitoring/library/Monitoring/DataView/Groupsummary.php index a623dab45..72b021fa9 100644 --- a/modules/monitoring/library/Monitoring/DataView/Groupsummary.php +++ b/modules/monitoring/library/Monitoring/DataView/Groupsummary.php @@ -37,20 +37,4 @@ class Groupsummary extends DataView 'services_pending' ); } - - public function getSortRules() - { - if (in_array('servicegroup', $this->getQuery()->getColumns())) { - return array( - 'servicegroup' => array( - 'order' => self::SORT_ASC - ) - ); - } - return array( - 'hostgroup' => array( - 'order' => self::SORT_ASC - ) - ); - } }