summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Web/Widget
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icinga/Web/Widget')
-rw-r--r--library/Icinga/Web/Widget/AbstractWidget.php121
-rw-r--r--library/Icinga/Web/Widget/Announcements.php55
-rw-r--r--library/Icinga/Web/Widget/ApplicationStateMessages.php74
-rw-r--r--library/Icinga/Web/Widget/Chart/HistoryColorGrid.php400
-rw-r--r--library/Icinga/Web/Widget/Chart/InlinePie.php257
-rw-r--r--library/Icinga/Web/Widget/Dashboard.php475
-rw-r--r--library/Icinga/Web/Widget/Dashboard/Dashlet.php315
-rw-r--r--library/Icinga/Web/Widget/Dashboard/Pane.php335
-rw-r--r--library/Icinga/Web/Widget/Dashboard/UserWidget.php36
-rw-r--r--library/Icinga/Web/Widget/FilterEditor.php811
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php92
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationList.php133
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationListItem.php151
-rw-r--r--library/Icinga/Web/Widget/Limiter.php54
-rw-r--r--library/Icinga/Web/Widget/Paginator.php167
-rw-r--r--library/Icinga/Web/Widget/SearchDashboard.php111
-rw-r--r--library/Icinga/Web/Widget/SingleValueSearchControl.php200
-rw-r--r--library/Icinga/Web/Widget/SortBox.php260
-rw-r--r--library/Icinga/Web/Widget/Tab.php323
-rw-r--r--library/Icinga/Web/Widget/Tabextension/DashboardAction.php35
-rw-r--r--library/Icinga/Web/Widget/Tabextension/DashboardSettings.php39
-rw-r--r--library/Icinga/Web/Widget/Tabextension/MenuAction.php35
-rw-r--r--library/Icinga/Web/Widget/Tabextension/OutputFormat.php114
-rw-r--r--library/Icinga/Web/Widget/Tabextension/Tabextension.php25
-rw-r--r--library/Icinga/Web/Widget/Tabs.php453
-rw-r--r--library/Icinga/Web/Widget/Widget.php24
26 files changed, 5095 insertions, 0 deletions
diff --git a/library/Icinga/Web/Widget/AbstractWidget.php b/library/Icinga/Web/Widget/AbstractWidget.php
new file mode 100644
index 0000000..1090548
--- /dev/null
+++ b/library/Icinga/Web/Widget/AbstractWidget.php
@@ -0,0 +1,121 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Application\Icinga;
+use Exception;
+use Zend_View_Abstract;
+
+/**
+ * Web widgets MUST extend this class
+ *
+ * AbstractWidget implements getters and setters for widget options stored in
+ * the protected options array. If you want to allow options for your own
+ * widget, you have to set a default value (may be null) for each single option
+ * in this array.
+ *
+ * Please have a look at the available widgets in this folder to get a better
+ * idea on what they should look like.
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+abstract class AbstractWidget
+{
+ /**
+ * If you are going to access the current view with the view() function,
+ * its instance is stored here for performance reasons.
+ *
+ * @var Zend_View_Abstract
+ */
+ protected static $view;
+
+ // TODO: Should we kick this?
+ protected $properties = array();
+
+ /**
+ * Getter for widget properties
+ *
+ * @param string $key The option you're interested in
+ *
+ * @throws ProgrammingError for unknown property name
+ *
+ * @return mixed
+ */
+ public function __get($key)
+ {
+ if (array_key_exists($key, $this->properties)) {
+ return $this->properties[$key];
+ }
+
+ throw new ProgrammingError(
+ 'Trying to get invalid "%s" property for %s',
+ $key,
+ get_class($this)
+ );
+ }
+
+ /**
+ * Setter for widget properties
+ *
+ * @param string $key The option you want to set
+ * @param string $val The new value going to be assigned to this option
+ *
+ * @throws ProgrammingError for unknown property name
+ *
+ * @return mixed
+ */
+ public function __set($key, $val)
+ {
+ if (array_key_exists($key, $this->properties)) {
+ $this->properties[$key] = $val;
+ return;
+ }
+
+ throw new ProgrammingError(
+ 'Trying to set invalid "%s" property in %s. Allowed are: %s',
+ $key,
+ get_class($this),
+ empty($this->properties)
+ ? 'none'
+ : implode(', ', array_keys($this->properties))
+ );
+ }
+
+ abstract public function render();
+
+ /**
+ * Access the current view
+ *
+ * Will instantiate a new one if none exists
+ * // TODO: App->getView
+ *
+ * @return Zend_View_Abstract
+ */
+ protected function view()
+ {
+ if (self::$view === null) {
+ self::$view = Icinga::app()->getViewRenderer()->view;
+ }
+
+ return self::$view;
+ }
+
+ /**
+ * Cast this widget to a string. Will call your render() function
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ $html = $this->render();
+ } catch (Exception $e) {
+ return htmlspecialchars($e->getMessage());
+ }
+ return (string) $html;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Announcements.php b/library/Icinga/Web/Widget/Announcements.php
new file mode 100644
index 0000000..e0fac77
--- /dev/null
+++ b/library/Icinga/Web/Widget/Announcements.php
@@ -0,0 +1,55 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Icinga;
+use Icinga\Data\Filter\Filter;
+use Icinga\Forms\Announcement\AcknowledgeAnnouncementForm;
+use Icinga\Web\Announcement\AnnouncementCookie;
+use Icinga\Web\Announcement\AnnouncementIniRepository;
+use Icinga\Web\Helper\Markdown;
+
+/**
+ * Render announcements
+ */
+class Announcements extends AbstractWidget
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ $repo = new AnnouncementIniRepository();
+ $etag = $repo->getEtag();
+ $cookie = new AnnouncementCookie();
+ if ($cookie->getEtag() !== $etag) {
+ $cookie->setEtag($etag);
+ $cookie->setNextActive($repo->findNextActive());
+ Icinga::app()->getResponse()->setCookie($cookie);
+ }
+ $acked = array();
+ foreach ($cookie->getAcknowledged() as $hash) {
+ $acked[] = Filter::expression('hash', '!=', $hash);
+ }
+ $acked = Filter::matchAll($acked);
+ $announcements = $repo->findActive();
+ $announcements->applyFilter($acked);
+ if ($announcements->hasResult()) {
+ $html = '<ul role="alert">';
+ foreach ($announcements as $announcement) {
+ $ackForm = new AcknowledgeAnnouncementForm();
+ $ackForm->populate(array('hash' => $announcement->hash));
+ $html .= '<li><div class="message">'
+ . Markdown::text($announcement->message)
+ . '</div>'
+ . $ackForm
+ . '</li>';
+ }
+ $html .= '</ul>';
+ return $html;
+ }
+ // Force container update on XHR
+ return '<div hidden></div>';
+ }
+}
diff --git a/library/Icinga/Web/Widget/ApplicationStateMessages.php b/library/Icinga/Web/Widget/ApplicationStateMessages.php
new file mode 100644
index 0000000..99d3bb2
--- /dev/null
+++ b/library/Icinga/Web/Widget/ApplicationStateMessages.php
@@ -0,0 +1,74 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Config;
+use Icinga\Application\Hook\ApplicationStateHook;
+use Icinga\Authentication\Auth;
+use Icinga\Forms\AcknowledgeApplicationStateMessageForm;
+use Icinga\Web\ApplicationStateCookie;
+use Icinga\Web\Helper\Markdown;
+
+/**
+ * Render application state messages
+ */
+class ApplicationStateMessages extends AbstractWidget
+{
+ protected function getMessages()
+ {
+ $cookie = new ApplicationStateCookie();
+
+ $acked = array_flip($cookie->getAcknowledgedMessages());
+ $messages = ApplicationStateHook::getAllMessages();
+
+ $active = array_diff_key($messages, $acked);
+
+ return $active;
+ }
+
+ public function render()
+ {
+ $enabled = Auth::getInstance()
+ ->getUser()
+ ->getPreferences()
+ ->getValue('icingaweb', 'show_application_state_messages', 'system');
+
+ if ($enabled === 'system') {
+ $enabled = Config::app()->get('global', 'show_application_state_messages', true);
+ }
+
+ if (! (bool) $enabled) {
+ return '<div hidden></div>';
+ }
+
+ $active = $this->getMessages();
+
+ if (empty($active)) {
+ // Force container update on XHR
+ return '<div hidden></div>';
+ }
+
+ $html = '<div>';
+
+ reset($active);
+
+ $id = key($active);
+ $spec = current($active);
+ $message = array_pop($spec); // We don't use state and timestamp here
+
+
+ $ackForm = new AcknowledgeApplicationStateMessageForm();
+ $ackForm->populate(['id' => $id]);
+
+ $html .= '<section class="markdown">';
+ $html .= Markdown::text($message);
+ $html .= '</section>';
+
+ $html .= $ackForm;
+
+ $html .= '</div>';
+
+ return $html;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php
new file mode 100644
index 0000000..b7b50d0
--- /dev/null
+++ b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php
@@ -0,0 +1,400 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Chart;
+
+use DateInterval;
+use DateTime;
+use Icinga\Util\Color;
+use Icinga\Util\Csp;
+use Icinga\Web\Widget\AbstractWidget;
+use ipl\Web\Style;
+
+/**
+ * Display a colored grid that visualizes a set of values for each day
+ * on a given time-frame.
+ */
+class HistoryColorGrid extends AbstractWidget
+{
+ const CAL_GROW_INTO_PAST = 'past';
+ const CAL_GROW_INTO_PRESENT = 'present';
+
+ const ORIENTATION_VERTICAL = 'vertical';
+ const ORIENTATION_HORIZONTAL = 'horizontal';
+
+ public $weekFlow = self::CAL_GROW_INTO_PAST;
+ public $orientation = self::ORIENTATION_VERTICAL;
+ public $weekStartMonday = true;
+
+ private $maxValue = 1;
+
+ private $start = null;
+ private $end = null;
+ private $data = array();
+ private $color;
+ public $opacity = 1.0;
+
+ /** @var array<string, array<string, string>> History grid css rulesets */
+ protected $rulesets = [];
+
+ public function __construct($color = '#51e551', $start = null, $end = null)
+ {
+ $this->setColor($color);
+ if (isset($start)) {
+ $this->start = $this->tsToDateStr($start);
+ }
+ if (isset($end)) {
+ $this->end = $this->tsToDateStr($end);
+ }
+ }
+
+ /**
+ * Set the displayed data-set
+ *
+ * @param $events array The history events to display as an array of arrays:
+ * value: The value to display
+ * caption: The caption on mouse-over
+ * url: The url to open on click.
+ */
+ public function setData(array $events)
+ {
+ $this->data = $events;
+ $start = time();
+ $end = time();
+ foreach ($this->data as $entry) {
+ $entry['value'] = intval($entry['value']);
+ }
+ foreach ($this->data as $date => $entry) {
+ $time = strtotime($date);
+ if ($entry['value'] > $this->maxValue) {
+ $this->maxValue = $entry['value'];
+ }
+ if ($time > $end) {
+ $end = $time;
+ }
+ if ($time < $start) {
+ $start = $time;
+ }
+ }
+ if (!isset($this->start)) {
+ $this->start = $this->tsToDateStr($start);
+ }
+ if (!isset($this->end)) {
+ $this->end = $this->tsToDateStr($end);
+ }
+ }
+
+ /**
+ * Set the used color.
+ *
+ * @param $color
+ */
+ public function setColor($color)
+ {
+ $this->color = $color;
+ }
+
+ /**
+ * Set the used opacity
+ *
+ * @param $opacity
+ */
+ public function setOpacity($opacity)
+ {
+ $this->opacity = $opacity;
+ }
+
+ /**
+ * Calculate the color to display for the given value.
+ *
+ * @param $value integer
+ *
+ * @return string The color-string to use for this entry.
+ */
+ private function calculateColor($value)
+ {
+ $saturation = $value / $this->maxValue;
+ return Color::changeSaturation($this->color, $saturation);
+ }
+
+ /**
+ * Render the html to display the given $day
+ *
+ * @param $day string The day to display YYYY-MM-DD
+ *
+ * @return string The rendered html
+ */
+ private function renderDay($day)
+ {
+ if (array_key_exists($day, $this->data) && $this->data[$day]['value'] > 0) {
+ $entry = $this->data[$day];
+ $this->rulesets['.grid-day-with-entry-' . $entry['value']] = [
+ 'background-color' => $this->calculateColor($entry['value']),
+ 'opacity' => $this->opacity
+ ];
+
+ return '<a class="grid-day-with-entry-'
+ . $entry['value']
+ . '" '
+ . 'aria-label="' . $entry['caption']
+ . '" '
+ . 'title="' . $entry['caption']
+ . '" '
+ . 'href="' . $entry['url']
+ . '" '
+ . '"></a>';
+ } else {
+ if (! isset($this->rulesets['.grid-day-no-entry'])) {
+ $this->rulesets['.grid-day-no-entry'] = [
+ 'background-color' => $this->calculateColor(0),
+ 'opacity' => $this->opacity
+ ];
+ }
+
+ return '<span class="grid-day-no-entry"' . ' title="No entries for ' . $day . '"></span>';
+ }
+ }
+
+ /**
+ * Render the grid with an horizontal alignment.
+ *
+ * @param array $grid The values returned from the createGrid function
+ *
+ * @return string The rendered html
+ */
+ private function renderHorizontal($grid)
+ {
+ $weeks = $grid['weeks'];
+ $months = $grid['months'];
+ $years = $grid['years'];
+ $html = '<table class="historycolorgrid">';
+ $html .= '<tr><th></th>';
+ $old = -1;
+ foreach ($months as $week => $month) {
+ if ($old !== $month) {
+ $old = $month;
+ $txt = $this->monthName($month, $years[$week]);
+ } else {
+ $txt = '';
+ }
+ $html .= '<th>' . $txt . '</th>';
+ }
+ $html .= '</tr>';
+ for ($i = 0; $i < 7; $i++) {
+ $html .= $this->renderWeekdayHorizontal($i, $weeks);
+ }
+ $html .= '</table>';
+ return $html;
+ }
+
+ /**
+ * @param $grid
+ *
+ * @return string
+ */
+ private function renderVertical($grid)
+ {
+ $years = $grid['years'];
+ $weeks = $grid['weeks'];
+ $months = $grid['months'];
+ $html = '<table class="historycolorgrid">';
+ $html .= '<tr>';
+ for ($i = 0; $i < 7; $i++) {
+ $html .= '<th>' . $this->weekdayName($this->weekStartMonday ? $i + 1 : $i) . "</th>";
+ }
+ $html .= '</tr>';
+ $old = -1;
+ foreach ($weeks as $index => $week) {
+ for ($i = 0; $i < 7; $i++) {
+ if (array_key_exists($i, $week)) {
+ $html .= '<td>' . $this->renderDay($week[$i]) . '</td>';
+ } else {
+ $html .= '<td></td>';
+ }
+ }
+ if ($old !== $months[$index]) {
+ $old = $months[$index];
+ $txt = $this->monthName($old, $years[$index]);
+ } else {
+ $txt = '';
+ }
+ $html .= '<td class="weekday">' . $txt . '</td></tr>';
+ }
+ $html .= '</table>';
+ return $html;
+ }
+
+ /**
+ * Render the row for the given weekday.
+ *
+ * @param integer $weekday The day to render (0-6)
+ * @param array $weeks The weeks
+ *
+ * @return string The formatted table-row
+ */
+ private function renderWeekdayHorizontal($weekday, &$weeks)
+ {
+ $html = '<tr><td class="weekday">'
+ . $this->weekdayName($this->weekStartMonday ? $weekday + 1 : $weekday)
+ . '</td>';
+ foreach ($weeks as $week) {
+ if (array_key_exists($weekday, $week)) {
+ $html .= '<td>' . $this->renderDay($week[$weekday]) . '</td>';
+ } else {
+ $html .= '<td></td>';
+ }
+ }
+ $html .= '</tr>';
+ return $html;
+ }
+
+
+
+ /**
+ * @return array
+ */
+ private function createGrid()
+ {
+ $weeks = array(array());
+ $week = 0;
+ $months = array();
+ $years = array();
+ $start = strtotime($this->start);
+ $year = intval(date('Y', $start));
+ $month = intval(date('n', $start));
+ $day = intval(date('j', $start));
+ $weekday = intval(date('w', $start));
+ if ($this->weekStartMonday) {
+ // 0 => monday, 6 => sunday
+ $weekday = $weekday === 0 ? 6 : $weekday - 1;
+ }
+
+ $date = $this->toDateStr($day, $month, $year);
+ $weeks[0][$weekday] = $date;
+ $years[0] = $year;
+ $months[0] = $month;
+ while ($date !== $this->end) {
+ $day++;
+ $weekday++;
+ if ($weekday > 6) {
+ $weekday = 0;
+ $weeks[] = array();
+ // PRESENT => The last day of week determines the month
+ if ($this->weekFlow === self::CAL_GROW_INTO_PRESENT) {
+ $months[$week] = $month;
+ $years[$week] = $year;
+ }
+ $week++;
+ }
+ if ($day > date('t', mktime(0, 0, 0, $month, 1, $year))) {
+ $month++;
+ if ($month > 12) {
+ $year++;
+ $month = 1;
+ }
+ $day = 1;
+ }
+ if ($weekday === 0) {
+ // PAST => The first day of each week determines the month
+ if ($this->weekFlow === self::CAL_GROW_INTO_PAST) {
+ $months[$week] = $month;
+ $years[$week] = $year;
+ }
+ }
+ $date = $this->toDateStr($day, $month, $year);
+ $weeks[$week][$weekday] = $date;
+ };
+ $years[$week] = $year;
+ $months[$week] = $month;
+ if ($this->weekFlow == self::CAL_GROW_INTO_PAST) {
+ return array(
+ 'weeks' => array_reverse($weeks),
+ 'months' => array_reverse($months),
+ 'years' => array_reverse($years)
+ );
+ }
+ return array(
+ 'weeks' => $weeks,
+ 'months' => $months,
+ 'years' => $years
+ );
+ }
+
+ /**
+ * Get the localized month-name for the given month
+ *
+ * @param integer $month The month-number
+ *
+ * @return string The
+ */
+ private function monthName($month, $year)
+ {
+ // TODO: find a way to render years without messing up the layout
+ $dt = new DateTime($year . '-' . $month . '-01');
+ return $dt->format('M');
+ }
+
+ /**
+ * @param $weekday
+ *
+ * @return string
+ */
+ private function weekdayName($weekday)
+ {
+ $sun = new DateTime('last Sunday');
+ $interval = new DateInterval('P' . $weekday . 'D');
+ $sun->add($interval);
+ return substr($sun->format('D'), 0, 2);
+ }
+
+ /**
+ *
+ *
+ * @param $timestamp
+ *
+ * @return bool|string
+ */
+ private function tsToDateStr($timestamp)
+ {
+ return date('Y-m-d', $timestamp);
+ }
+
+ /**
+ * @param $day
+ * @param $mon
+ * @param $year
+ *
+ * @return string
+ */
+ private function toDateStr($day, $mon, $year)
+ {
+ $day = $day > 9 ? (string)$day : '0' . (string)$day;
+ $mon = $mon > 9 ? (string)$mon : '0' . (string)$mon;
+ return $year . '-' . $mon . '-' . $day;
+ }
+
+ /**
+ * @return string
+ */
+ public function render()
+ {
+ if (empty($this->data)) {
+ return '<div>No entries</div>';
+ }
+ $grid = $this->createGrid();
+ if ($this->orientation === self::ORIENTATION_HORIZONTAL) {
+ $html = $this->renderHorizontal($grid);
+ } else {
+ $html = $this->renderVertical($grid);
+ }
+
+ $historyGridStyle = new Style();
+ $historyGridStyle->setNonce(Csp::getStyleNonce());
+
+ foreach ($this->rulesets as $selector => $properties) {
+ $historyGridStyle->add($selector, $properties);
+ }
+
+ return $html . $historyGridStyle;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Chart/InlinePie.php b/library/Icinga/Web/Widget/Chart/InlinePie.php
new file mode 100644
index 0000000..21b4ca4
--- /dev/null
+++ b/library/Icinga/Web/Widget/Chart/InlinePie.php
@@ -0,0 +1,257 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Chart;
+
+use Icinga\Chart\PieChart;
+use Icinga\Module\Monitoring\Plugin\PerfdataSet;
+use Icinga\Util\StringHelper;
+use Icinga\Web\Widget\AbstractWidget;
+use Icinga\Web\Url;
+use Icinga\Util\Format;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use stdClass;
+
+/**
+ * A SVG-PieChart intended to be displayed as a small icon next to labels, to offer a better visualization of the
+ * shown data
+ *
+ * NOTE: When InlinePies are shown in a dynamically loaded view, like the side-bar or in the dashboard, the SVGs will
+ * be replaced with a jQuery-Sparkline to save resources @see loader.js
+ *
+ * @package Icinga\Web\Widget\Chart
+ */
+class InlinePie extends AbstractWidget
+{
+ const NUMBER_FORMAT_NONE = 'none';
+ const NUMBER_FORMAT_TIME = 'time';
+ const NUMBER_FORMAT_BYTES = 'bytes';
+ const NUMBER_FORMAT_RATIO = 'ratio';
+
+ public static $colorsHostStates = array(
+ '#44bb77', // up
+ '#ff99aa', // down
+ '#cc77ff', // unreachable
+ '#77aaff' // pending
+ );
+
+ public static $colorsHostStatesHandledUnhandled = array(
+ '#44bb77', // up
+ '#44bb77',
+ '#ff99aa', // down
+ '#ff5566',
+ '#cc77ff', // unreachable
+ '#aa44ff',
+ '#77aaff', // pending
+ '#77aaff'
+ );
+
+ public static $colorsServiceStates = array(
+ '#44bb77', // Ok
+ '#ffaa44', // Warning
+ '#ff99aa', // Critical
+ '#aa44ff', // Unknown
+ '#77aaff' // Pending
+ );
+
+ public static $colorsServiceStatesHandleUnhandled = array(
+ '#44bb77', // Ok
+ '#44bb77',
+ '#ffaa44', // Warning
+ '#ffcc66',
+ '#ff99aa', // Critical
+ '#ff5566',
+ '#cc77ff', // Unknown
+ '#aa44ff',
+ '#77aaff', // Pending
+ '#77aaff'
+ );
+
+ /**
+ * The template string used for rendering this widget
+ *
+ * @var string
+ */
+ private $template = '<div class="inline-pie {class}">{svg}</div>';
+
+ /**
+ * The colors used to display the slices of this pie-chart.
+ *
+ * @var array
+ */
+ private $colors = array('#049BAF', '#ffaa44', '#ff5566', '#ddccdd');
+
+ /**
+ * The title of the chart
+ *
+ * @var string
+ */
+ private $title;
+
+ /**
+ * @var int
+ */
+ private $size = 16;
+
+ /**
+ * The data displayed by the pie-chart
+ *
+ * @var array
+ */
+ private $data;
+
+ /**
+ * @var
+ */
+ private $class = '';
+
+ /**
+ * Set the data to be displayed.
+ *
+ * @param $data array
+ *
+ * @return $this
+ */
+ public function setData(array $data)
+ {
+ $this->data = $data;
+
+ return $this;
+ }
+
+ /**
+ * Set the size of the inline pie
+ *
+ * @param int $size Sets both, the height and width
+ *
+ * @return $this
+ */
+ public function setSize($size = null)
+ {
+ $this->size = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the class to define the
+ *
+ * @param $class
+ *
+ * @return $this
+ */
+ public function setSparklineClass($class)
+ {
+ $this->class = $class;
+
+ return $this;
+ }
+
+ /**
+ * Set the colors used by the slices of the pie chart.
+ *
+ * @param array $colors
+ *
+ * @return $this
+ */
+ public function setColors(array $colors = null)
+ {
+ $this->colors = $colors;
+
+ return $this;
+ }
+
+ /**
+ * Set the title of the displayed Data
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $this->view()->escape($title);
+
+ return $this;
+ }
+
+ /**
+ * Create a new InlinePie
+ *
+ * @param array $data The data displayed by the slices
+ * @param string $title The title of this Pie
+ * @param array $colors An array of RGB-Color values to use
+ */
+ public function __construct(array $data, $title, $colors = null)
+ {
+ $this->setTitle($title);
+
+ if (array_key_exists('data', $data)) {
+ $this->data = $data['data'];
+ if (array_key_exists('colors', $data)) {
+ $this->colors = $data['colors'];
+ }
+ } else {
+ $this->setData($data);
+ }
+
+ if (isset($colors)) {
+ $this->setColors($colors);
+ } else {
+ $this->setColors($this->colors);
+ }
+ }
+
+ /**
+ * Renders this widget via the given view and returns the
+ * HTML as a string
+ *
+ * @return string
+ */
+ public function render()
+ {
+ $pie = new PieChart();
+ $pie->alignTopLeft();
+ $pie->disableLegend();
+ $pie->drawPie([
+ 'data' => $this->data,
+ 'colors' => $this->colors
+ ]);
+
+ if ($this->view()->layout()->getLayout() === 'pdf') {
+ try {
+ $png = $pie->toPng($this->size, $this->size);
+ return '<img class="inlinepie" src="data:image/png;base64,' . base64_encode($png) . '" />';
+ } catch (IcingaException $_) {
+ return '';
+ }
+ }
+
+ $pie->title = $this->title;
+ $pie->description = $this->title;
+
+ $template = $this->template;
+ $template = str_replace('{class}', $this->class, $template);
+ $template = str_replace('{svg}', $pie->render(), $template);
+
+ return $template;
+ }
+
+ public static function createFromStateSummary(stdClass $states, $title, array $colors)
+ {
+ $handledUnhandledStates = [];
+ foreach ($states as $key => $value) {
+ if (StringHelper::endsWith($key, '_handled') || StringHelper::endsWith($key, '_unhandled')) {
+ $handledUnhandledStates[$key] = $value;
+ }
+ }
+
+ $chart = new self(array_values($handledUnhandledStates), $title, $colors);
+
+ return $chart
+ ->setSize(50)
+ ->setTitle('')
+ ->setSparklineClass('sparkline-multi');
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard.php b/library/Icinga/Web/Widget/Dashboard.php
new file mode 100644
index 0000000..5a8796d
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard.php
@@ -0,0 +1,475 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Config;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Legacy\DashboardConfig;
+use Icinga\User;
+use Icinga\Web\Navigation\DashboardPane;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Dashboard\Dashlet as DashboardDashlet;
+use Icinga\Web\Widget\Dashboard\Pane;
+
+/**
+ * Dashboards display multiple views on a single page
+ *
+ * The terminology is as follows:
+ * - Dashlet: A single view showing a specific url
+ * - Pane: Aggregates one or more dashlets on one page, displays its title as a tab
+ * - Dashboard: Shows all panes
+ *
+ */
+class Dashboard extends AbstractWidget
+{
+ /**
+ * An array containing all panes of this dashboard
+ *
+ * @var array
+ */
+ private $panes = array();
+
+ /**
+ * The @see Icinga\Web\Widget\Tabs object for displaying displayable panes
+ *
+ * @var Tabs
+ */
+ protected $tabs;
+
+ /**
+ * The parameter that will be added to identify panes
+ *
+ * @var string
+ */
+ private $tabParam = 'pane';
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * Set the given tab name as active.
+ *
+ * @param string $name The tab name to activate
+ *
+ */
+ public function activate($name)
+ {
+ $this->getTabs()->activate($name);
+ }
+
+ /**
+ * Load Pane items provided by all enabled modules
+ *
+ * @return $this
+ */
+ public function load()
+ {
+ $navigation = new Navigation();
+ $navigation->load('dashboard-pane');
+
+ $panes = array();
+ foreach ($navigation as $dashboardPane) {
+ /** @var DashboardPane $dashboardPane */
+ $pane = new Pane($dashboardPane->getLabel());
+ foreach ($dashboardPane->getChildren() as $dashlet) {
+ $pane->addDashlet($dashlet->getLabel(), $dashlet->getUrl());
+ }
+
+ $panes[] = $pane;
+ }
+
+ $this->mergePanes($panes);
+ $this->loadUserDashboards($navigation);
+ return $this;
+ }
+
+ /**
+ * Create and return a Config object for this dashboard
+ *
+ * @return Config
+ */
+ public function getConfig()
+ {
+ $output = array();
+ foreach ($this->panes as $pane) {
+ if ($pane->isUserWidget()) {
+ $output[$pane->getName()] = $pane->toArray();
+ }
+ foreach ($pane->getDashlets() as $dashlet) {
+ if ($dashlet->isUserWidget()) {
+ $output[$pane->getName() . '.' . $dashlet->getName()] = $dashlet->toArray();
+ }
+ }
+ }
+
+ return DashboardConfig::fromArray($output)->setConfigFile($this->getConfigFile())->setUser($this->user);
+ }
+
+ /**
+ * Load user dashboards from all config files that match the username
+ */
+ protected function loadUserDashboards(Navigation $navigation)
+ {
+ foreach (DashboardConfig::listConfigFilesForUser($this->user) as $file) {
+ $this->loadUserDashboardsFromFile($file, $navigation);
+ }
+ }
+
+ /**
+ * Load user dashboards from the given config file
+ *
+ * @param string $file
+ *
+ * @return bool
+ */
+ protected function loadUserDashboardsFromFile($file, Navigation $dashboardNavigation)
+ {
+ try {
+ $config = Config::fromIni($file);
+ } catch (NotReadableError $e) {
+ return false;
+ }
+
+ if (! count($config)) {
+ return false;
+ }
+ $panes = array();
+ $dashlets = array();
+ foreach ($config as $key => $part) {
+ if (strpos($key, '.') === false) {
+ $dashboardPane = $dashboardNavigation->getItem($key);
+ if ($dashboardPane !== null) {
+ $key = $dashboardPane->getLabel();
+ }
+ if ($this->hasPane($key)) {
+ $panes[$key] = $this->getPane($key);
+ } else {
+ $panes[$key] = new Pane($key);
+ $panes[$key]->setTitle($part->title);
+ }
+ $panes[$key]->setUserWidget();
+ if ((bool) $part->get('disabled', false) === true) {
+ $panes[$key]->setDisabled();
+ }
+ } else {
+ list($paneName, $dashletName) = explode('.', $key, 2);
+ $dashboardPane = $dashboardNavigation->getItem($paneName);
+ if ($dashboardPane !== null) {
+ $paneName = $dashboardPane->getLabel();
+ $dashletItem = $dashboardPane->getChildren()->getItem($dashletName);
+ if ($dashletItem !== null) {
+ $dashletName = $dashletItem->getLabel();
+ }
+ }
+ $part->pane = $paneName;
+ $part->dashlet = $dashletName;
+ $dashlets[] = $part;
+ }
+ }
+ foreach ($dashlets as $dashletData) {
+ $pane = null;
+
+ if (array_key_exists($dashletData->pane, $panes) === true) {
+ $pane = $panes[$dashletData->pane];
+ } elseif (array_key_exists($dashletData->pane, $this->panes) === true) {
+ $pane = $this->panes[$dashletData->pane];
+ } else {
+ continue;
+ }
+ $dashlet = new DashboardDashlet(
+ $dashletData->title,
+ $dashletData->url,
+ $pane
+ );
+ $dashlet->setName($dashletData->dashlet);
+
+ if ((bool) $dashletData->get('disabled', false) === true) {
+ $dashlet->setDisabled(true);
+ }
+
+ $dashlet->setUserWidget();
+ $pane->addDashlet($dashlet);
+ }
+
+ $this->mergePanes($panes);
+
+ return true;
+ }
+
+ /**
+ * Merge panes with existing panes
+ *
+ * @param array $panes
+ *
+ * @return $this
+ */
+ public function mergePanes(array $panes)
+ {
+ /** @var $pane Pane */
+ foreach ($panes as $pane) {
+ if ($this->hasPane($pane->getName()) === true) {
+ /** @var $current Pane */
+ $current = $this->panes[$pane->getName()];
+ $current->addDashlets($pane->getDashlets());
+ } else {
+ $this->panes[$pane->getName()] = $pane;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the tab object used to navigate through this dashboard
+ *
+ * @return Tabs
+ */
+ public function getTabs()
+ {
+ $url = Url::fromPath('dashboard')->getUrlWithout($this->tabParam);
+ if ($this->tabs === null) {
+ $this->tabs = new Tabs();
+
+ foreach ($this->panes as $key => $pane) {
+ if ($pane->getDisabled()) {
+ continue;
+ }
+ $this->tabs->add(
+ $key,
+ array(
+ 'title' => sprintf(
+ t('Show %s', 'dashboard.pane.tooltip'),
+ $pane->getTitle()
+ ),
+ 'label' => $pane->getTitle(),
+ 'url' => clone($url),
+ 'urlParams' => array($this->tabParam => $key)
+ )
+ );
+ }
+ }
+ return $this->tabs;
+ }
+
+ /**
+ * Return all panes of this dashboard
+ *
+ * @return array
+ */
+ public function getPanes()
+ {
+ return $this->panes;
+ }
+
+
+ /**
+ * Creates a new empty pane with the given title
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function createPane($title)
+ {
+ $pane = new Pane($title);
+ $pane->setTitle($title);
+ $this->addPane($pane);
+
+ return $this;
+ }
+
+ /**
+ * Checks if the current dashboard has any panes
+ *
+ * @return bool
+ */
+ public function hasPanes()
+ {
+ return ! empty($this->panes);
+ }
+
+ /**
+ * Check if a panel exist
+ *
+ * @param string $pane
+ * @return bool
+ */
+ public function hasPane($pane)
+ {
+ return $pane && array_key_exists($pane, $this->panes);
+ }
+
+ /**
+ * Add a pane object to this dashboard
+ *
+ * @param Pane $pane The pane to add
+ *
+ * @return $this
+ */
+ public function addPane(Pane $pane)
+ {
+ $this->panes[$pane->getName()] = $pane;
+ return $this;
+ }
+
+ public function removePane($title)
+ {
+ if ($this->hasPane($title) === true) {
+ $pane = $this->getPane($title);
+ if ($pane->isUserWidget() === true) {
+ unset($this->panes[$pane->getName()]);
+ } else {
+ $pane->setDisabled();
+ $pane->setUserWidget();
+ }
+ } else {
+ throw new ProgrammingError('Pane not found: ' . $title);
+ }
+ }
+
+ /**
+ * Return the pane with the provided name
+ *
+ * @param string $name The name of the pane to return
+ *
+ * @return Pane The pane or null if no pane with the given name exists
+ * @throws ProgrammingError
+ */
+ public function getPane($name)
+ {
+ if (! array_key_exists($name, $this->panes)) {
+ throw new ProgrammingError(
+ 'Trying to retrieve invalid dashboard pane "%s"',
+ $name
+ );
+ }
+ return $this->panes[$name];
+ }
+
+ /**
+ * Return an array with pane name=>title format used for comboboxes
+ *
+ * @return array
+ */
+ public function getPaneKeyTitleArray()
+ {
+ $list = array();
+ foreach ($this->panes as $name => $pane) {
+ $list[$name] = $pane->getTitle();
+ }
+ return $list;
+ }
+
+ /**
+ * @see Icinga\Web\Widget::render
+ */
+ public function render()
+ {
+ if (empty($this->panes)) {
+ return '';
+ }
+
+ return $this->determineActivePane()->render();
+ }
+
+ /**
+ * Activates the default pane of this dashboard and returns its name
+ *
+ * @return mixed
+ */
+ private function setDefaultPane()
+ {
+ $active = null;
+
+ foreach ($this->panes as $key => $pane) {
+ if ($pane->getDisabled() === false) {
+ $active = $key;
+ break;
+ }
+ }
+
+ if ($active !== null) {
+ $this->activate($active);
+ }
+ return $active;
+ }
+
+ /**
+ * @see determineActivePane()
+ */
+ public function getActivePane()
+ {
+ return $this->determineActivePane();
+ }
+
+ /**
+ * Determine the active pane either by the selected tab or the current request
+ *
+ * @throws \Icinga\Exception\ConfigurationError
+ * @throws \Icinga\Exception\ProgrammingError
+ *
+ * @return Pane The currently active pane
+ */
+ public function determineActivePane()
+ {
+ $active = $this->getTabs()->getActiveName();
+ if (! $active) {
+ if ($active = Url::fromRequest()->getParam($this->tabParam)) {
+ if ($this->hasPane($active)) {
+ $this->activate($active);
+ } else {
+ throw new ProgrammingError(
+ 'Try to get an inexistent pane.'
+ );
+ }
+ } else {
+ $active = $this->setDefaultPane();
+ }
+ }
+
+ if (isset($this->panes[$active])) {
+ return $this->panes[$active];
+ }
+
+ throw new ConfigurationError('Could not determine active pane');
+ }
+
+ /**
+ * Setter for user object
+ *
+ * @param User $user
+ */
+ public function setUser(User $user)
+ {
+ $this->user = $user;
+ }
+
+ /**
+ * Getter for user object
+ *
+ * @return User
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Get config file
+ *
+ * @return string
+ */
+ public function getConfigFile()
+ {
+ if ($this->user === null) {
+ throw new ProgrammingError('Can\'t load dashboards. User is not set');
+ }
+ return Config::resolvePath('dashboards/' . strtolower($this->user->getUsername()) . '/dashboard.ini');
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard/Dashlet.php b/library/Icinga/Web/Widget/Dashboard/Dashlet.php
new file mode 100644
index 0000000..2ba26df
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard/Dashlet.php
@@ -0,0 +1,315 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Dashboard;
+
+use Icinga\Web\Url;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\IcingaException;
+
+/**
+ * A dashboard pane dashlet
+ *
+ * This is the element displaying a specific view in icinga2web
+ *
+ */
+class Dashlet extends UserWidget
+{
+ /**
+ * The url of this Dashlet
+ *
+ * @var Url|null
+ */
+ private $url;
+
+ private $name;
+
+ /**
+ * The title being displayed on top of the dashlet
+ * @var
+ */
+ private $title;
+
+ /**
+ * The pane containing this dashlet, needed for the 'remove button'
+ * @var Pane
+ */
+ private $pane;
+
+ /**
+ * The disabled option is used to "delete" default dashlets provided by modules
+ *
+ * @var bool
+ */
+ private $disabled = false;
+
+ /**
+ * The progress label being used
+ *
+ * @var string
+ */
+ private $progressLabel;
+
+ /**
+ * The template string used for rendering this widget
+ *
+ * @var string
+ */
+ private $template =<<<'EOD'
+
+ <div class="container" data-icinga-url="{URL}">
+ <h1><a href="{FULL_URL}" aria-label="{TOOLTIP}" title="{TOOLTIP}" data-base-target="col1">{TITLE}</a></h1>
+ <p class="progress-label">{PROGRESS_LABEL}<span>.</span><span>.</span><span>.</span></p>
+ <noscript>
+ <div class="iframe-container">
+ <iframe
+ src="{IFRAME_URL}"
+ frameborder="no"
+ title="{TITLE_PREFIX}{TITLE}">
+ </iframe>
+ </div>
+ </noscript>
+ </div>
+EOD;
+
+ /**
+ * The template string used for rendering this widget in case of an error
+ *
+ * @var string
+ */
+ private $errorTemplate = <<<'EOD'
+
+ <div class="container">
+ <h1 title="{TOOLTIP}">{TITLE}</h1>
+ <p class="error-message">{ERROR_MESSAGE}</p>
+ </div>
+EOD;
+
+ /**
+ * Create a new dashlet displaying the given url in the provided pane
+ *
+ * @param string $title The title to use for this dashlet
+ * @param Url|string $url The url this dashlet uses for displaying information
+ * @param Pane $pane The pane this Dashlet will be added to
+ */
+ public function __construct($title, $url, Pane $pane)
+ {
+ $this->name = $title;
+ $this->title = $title;
+ $this->pane = $pane;
+ $this->url = $url;
+ }
+
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Retrieve the dashlets title
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ /**
+ * @param string $title
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ }
+
+ /**
+ * Retrieve the dashlets url
+ *
+ * @return Url|null
+ */
+ public function getUrl()
+ {
+ if ($this->url !== null && ! $this->url instanceof Url) {
+ $this->url = Url::fromPath($this->url);
+ }
+ return $this->url;
+ }
+
+ /**
+ * Set the dashlets URL
+ *
+ * @param string|Url $url The url to use, either as an Url object or as a path
+ *
+ * @return $this
+ */
+ public function setUrl($url)
+ {
+ $this->url = $url;
+ 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;
+ }
+
+ /**
+ * Set the progress label to use
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setProgressLabel($label)
+ {
+ $this->progressLabel = $label;
+ return $this;
+ }
+
+ /**
+ * Return the progress label to use
+ *
+ * @return string
+ */
+ public function getProgressLabe()
+ {
+ if ($this->progressLabel === null) {
+ return $this->view()->translate('Loading');
+ }
+
+ return $this->progressLabel;
+ }
+
+ /**
+ * Return this dashlet's structure as array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $array = array(
+ 'url' => $this->getUrl()->getRelativeUrl(),
+ 'title' => $this->getTitle()
+ );
+ if ($this->getDisabled() === true) {
+ $array['disabled'] = 1;
+ }
+ return $array;
+ }
+
+ /**
+ * @see Widget::render()
+ */
+ public function render()
+ {
+ if ($this->disabled === true) {
+ return '';
+ }
+
+ $view = $this->view();
+
+ if (! $this->url) {
+ $searchTokens = array(
+ '{TOOLTIP}',
+ '{TITLE}',
+ '{ERROR_MESSAGE}'
+ );
+
+ $replaceTokens = array(
+ sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())),
+ $view->escape($this->getTitle()),
+ $view->escape(
+ sprintf($view->translate('Cannot create dashboard dashlet "%s" without valid URL'), $this->title)
+ )
+ );
+
+ return str_replace($searchTokens, $replaceTokens, $this->errorTemplate);
+ }
+
+ $url = $this->getUrl();
+ $url->setParam('showCompact', true);
+ $iframeUrl = clone $url;
+ $iframeUrl->setParam('isIframe');
+
+ $searchTokens = array(
+ '{URL}',
+ '{IFRAME_URL}',
+ '{FULL_URL}',
+ '{TOOLTIP}',
+ '{TITLE}',
+ '{TITLE_PREFIX}',
+ '{PROGRESS_LABEL}'
+ );
+
+ $replaceTokens = array(
+ $url,
+ $iframeUrl,
+ $url->getUrlWithout(['showCompact', 'limit', 'view']),
+ sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())),
+ $view->escape($this->getTitle()),
+ $view->translate('Dashlet') . ': ',
+ $this->getProgressLabe()
+ );
+
+ return str_replace($searchTokens, $replaceTokens, $this->template);
+ }
+
+ /**
+ * Create a @see Dashlet instance from the given Zend config, using the provided title
+ *
+ * @param $title The title for this dashlet
+ * @param ConfigObject $config The configuration defining url, parameters, height, width, etc.
+ * @param Pane $pane The pane this dashlet belongs to
+ *
+ * @return Dashlet A newly created Dashlet for use in the Dashboard
+ */
+ public static function fromIni($title, ConfigObject $config, Pane $pane)
+ {
+ $height = null;
+ $width = null;
+ $url = $config->get('url');
+ $parameters = $config->toArray();
+ unset($parameters['url']); // otherwise there's an url = parameter in the Url
+
+ $cmp = new Dashlet($title, Url::fromPath($url, $parameters), $pane);
+ return $cmp;
+ }
+
+ /**
+ * @param \Icinga\Web\Widget\Dashboard\Pane $pane
+ */
+ public function setPane(Pane $pane)
+ {
+ $this->pane = $pane;
+ }
+
+ /**
+ * @return \Icinga\Web\Widget\Dashboard\Pane
+ */
+ public function getPane()
+ {
+ return $this->pane;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard/Pane.php b/library/Icinga/Web/Widget/Dashboard/Pane.php
new file mode 100644
index 0000000..c8b14c5
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard/Pane.php
@@ -0,0 +1,335 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Dashboard;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Web\Widget\AbstractWidget;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * A pane, displaying different Dashboard dashlets
+ */
+class Pane extends UserWidget
+{
+ /**
+ * The name of this pane, as defined in the ini file
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * The title of this pane, as displayed in the dashboard tabs
+ *
+ * @var string
+ */
+ private $title;
+
+ /**
+ * An array of @see Dashlets that are displayed in this pane
+ *
+ * @var array
+ */
+ private $dashlets = array();
+
+ /**
+ * Disabled flag of a pane
+ *
+ * @var bool
+ */
+ private $disabled = false;
+
+ /**
+ * Create a new pane
+ *
+ * @param string $name The pane to create
+ */
+ public function __construct($name)
+ {
+ $this->name = $name;
+ $this->title = $name;
+ }
+
+ /**
+ * Set the name of this pane
+ *
+ * @param string $name
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ }
+
+ /**
+ * Returns the name of this pane
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Returns the title of this pane
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ /**
+ * Overwrite the title of this pane
+ *
+ * @param string $title The new title to use for this pane
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * Return true if a dashlet with the given title exists in this pane
+ *
+ * @param string $title The title of the dashlet to check for existence
+ *
+ * @return bool
+ */
+ public function hasDashlet($title)
+ {
+ return array_key_exists($title, $this->dashlets);
+ }
+
+ /**
+ * Checks if the current pane has any dashlets
+ *
+ * @return bool
+ */
+ public function hasDashlets()
+ {
+ return ! empty($this->dashlets);
+ }
+
+ /**
+ * Return a dashlet with the given name if existing
+ *
+ * @param string $title The title of the dashlet to return
+ *
+ * @return Dashlet The dashlet with the given title
+ * @throws ProgrammingError If the dashlet doesn't exist
+ */
+ public function getDashlet($title)
+ {
+ if ($this->hasDashlet($title)) {
+ return $this->dashlets[$title];
+ }
+ throw new ProgrammingError(
+ 'Trying to access invalid dashlet: %s',
+ $title
+ );
+ }
+
+ /**
+ * Removes the dashlet with the given title if it exists in this pane
+ *
+ * @param string $title The pane
+ * @return Pane $this
+ */
+ public function removeDashlet($title)
+ {
+ if ($this->hasDashlet($title)) {
+ $dashlet = $this->getDashlet($title);
+ if ($dashlet->isUserWidget() === true) {
+ unset($this->dashlets[$title]);
+ } else {
+ $dashlet->setDisabled(true);
+ $dashlet->setUserWidget();
+ }
+ } else {
+ throw new ProgrammingError('Dashlet does not exist: ' . $title);
+ }
+ return $this;
+ }
+
+ /**
+ * Removes all or a given list of dashlets from this pane
+ *
+ * @param array $dashlets Optional list of dashlet titles
+ * @return Pane $this
+ */
+ public function removeDashlets(array $dashlets = null)
+ {
+ if ($dashlets === null) {
+ $this->dashlets = array();
+ } else {
+ foreach ($dashlets as $dashlet) {
+ $this->removeDashlet($dashlet);
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Return all dashlets added at this pane
+ *
+ * @return array
+ */
+ public function getDashlets()
+ {
+ return $this->dashlets;
+ }
+
+ /**
+ * @see Widget::render
+ */
+ public function render()
+ {
+ $dashlets = array_filter(
+ $this->dashlets,
+ function ($e) {
+ return ! $e->getDisabled();
+ }
+ );
+ return implode("\n", $dashlets) . "\n";
+ }
+
+ /**
+ * Create, add and return a new dashlet
+ *
+ * @param string $title
+ * @param string $url
+ *
+ * @return Dashlet
+ */
+ public function createDashlet($title, $url = null)
+ {
+ $dashlet = new Dashlet($title, $url, $this);
+ $this->addDashlet($dashlet);
+ return $dashlet;
+ }
+
+ /**
+ * Add a dashlet to this pane, optionally creating it if $dashlet is a string
+ *
+ * @param string|Dashlet $dashlet The dashlet object or title
+ * (if a new dashlet will be created)
+ * @param string|null $url An Url to be used when dashlet is a string
+ *
+ * @return $this
+ * @throws \Icinga\Exception\ConfigurationError
+ */
+ public function addDashlet($dashlet, $url = null)
+ {
+ if ($dashlet instanceof Dashlet) {
+ $this->dashlets[$dashlet->getName()] = $dashlet;
+ } elseif (is_string($dashlet) && $url !== null) {
+ $this->createDashlet($dashlet, $url);
+ } else {
+ throw new ConfigurationError('Invalid dashlet added: %s', $dashlet);
+ }
+ return $this;
+ }
+
+ /**
+ * Add new dashlets to existing dashlets
+ *
+ * @param array $dashlets
+ * @return $this
+ */
+ public function addDashlets(array $dashlets)
+ {
+ /* @var $dashlet Dashlet */
+ foreach ($dashlets as $dashlet) {
+ if (array_key_exists($dashlet->getName(), $this->dashlets)) {
+ if (preg_match('/_(\d+)$/', $dashlet->getName(), $m)) {
+ $name = preg_replace('/_\d+$/', $m[1]++, $dashlet->getName());
+ } else {
+ $name = $dashlet->getName() . '_2';
+ }
+ $this->dashlets[$name] = $dashlet;
+ } else {
+ $this->dashlets[$dashlet->getName()] = $dashlet;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a dashlet to the current pane
+ *
+ * @param $title
+ * @param $url
+ * @return Dashlet
+ *
+ * @see addDashlet()
+ */
+ public function add($title, $url = null)
+ {
+ $this->addDashlet($title, $url);
+
+ return $this->dashlets[$title];
+ }
+
+ /**
+ * Return the this pane's structure as array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $pane = array(
+ 'title' => $this->getTitle(),
+ );
+
+ if ($this->getDisabled() === true) {
+ $pane['disabled'] = 1;
+ }
+
+ return $pane;
+ }
+
+ /**
+ * Create a new pane with the title $title from the given configuration
+ *
+ * @param $title The title for this pane
+ * @param ConfigObject $config The configuration to use for setup
+ *
+ * @return Pane
+ */
+ public static function fromIni($title, ConfigObject $config)
+ {
+ $pane = new Pane($title);
+ if ($config->get('title', false)) {
+ $pane->setTitle($config->get('title'));
+ }
+ return $pane;
+ }
+
+ /**
+ * Setter for disabled
+ *
+ * @param boolean $disabled
+ */
+ public function setDisabled($disabled = true)
+ {
+ $this->disabled = (bool) $disabled;
+ }
+
+ /**
+ * Getter for disabled
+ *
+ * @return boolean
+ */
+ public function getDisabled()
+ {
+ return $this->disabled;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Dashboard/UserWidget.php b/library/Icinga/Web/Widget/Dashboard/UserWidget.php
new file mode 100644
index 0000000..164d58b
--- /dev/null
+++ b/library/Icinga/Web/Widget/Dashboard/UserWidget.php
@@ -0,0 +1,36 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Dashboard;
+
+use Icinga\Web\Widget\AbstractWidget;
+
+abstract class UserWidget extends AbstractWidget
+{
+ /**
+ * Flag if widget is created by an user
+ *
+ * @var bool
+ */
+ protected $userWidget = false;
+
+ /**
+ * Set the user widget flag
+ *
+ * @param boolean $userWidget
+ */
+ public function setUserWidget($userWidget = true)
+ {
+ $this->userWidget = (bool) $userWidget;
+ }
+
+ /**
+ * Getter for user widget flag
+ *
+ * @return boolean
+ */
+ public function isUserWidget()
+ {
+ return $this->userWidget;
+ }
+}
diff --git a/library/Icinga/Web/Widget/FilterEditor.php b/library/Icinga/Web/Widget/FilterEditor.php
new file mode 100644
index 0000000..24f4b15
--- /dev/null
+++ b/library/Icinga/Web/Widget/FilterEditor.php
@@ -0,0 +1,811 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Data\Filterable;
+use Icinga\Data\FilterColumns;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterOr;
+use Icinga\Web\Url;
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Notification;
+use Exception;
+
+/**
+ * Filter
+ */
+class FilterEditor extends AbstractWidget
+{
+ /**
+ * The filter
+ *
+ * @var Filter
+ */
+ private $filter;
+
+ /**
+ * The query to filter
+ *
+ * @var Filterable
+ */
+ protected $query;
+
+ protected $url;
+
+ protected $addTo;
+
+ protected $cachedColumnSelect;
+
+ protected $preserveParams = array();
+
+ protected $preservedParams = array();
+
+ protected $preservedUrl;
+
+ protected $ignoreParams = array();
+
+ protected $searchColumns;
+
+ /**
+ * @var string
+ */
+ private $selectedIdx;
+
+ /**
+ * Whether the filter control is visible
+ *
+ * @var bool
+ */
+ protected $visible = true;
+
+ /**
+ * Create a new FilterEditor
+ *
+ * @param Filter $filter Your filter
+ */
+ public function __construct($props)
+ {
+ if (array_key_exists('filter', $props)) {
+ $this->setFilter($props['filter']);
+ }
+ if (array_key_exists('query', $props)) {
+ $this->setQuery($props['query']);
+ }
+ }
+
+ public function setFilter(Filter $filter)
+ {
+ $this->filter = $filter;
+ return $this;
+ }
+
+ public function getFilter()
+ {
+ if ($this->filter === null) {
+ $this->filter = Filter::fromQueryString((string) $this->url()->getParams());
+ }
+ return $this->filter;
+ }
+
+ /**
+ * Set columns to search in
+ *
+ * @param array $searchColumns
+ *
+ * @return $this
+ */
+ public function setSearchColumns(array $searchColumns = null)
+ {
+ $this->searchColumns = $searchColumns;
+ return $this;
+ }
+
+ public function setUrl($url)
+ {
+ $this->url = $url;
+ return $this;
+ }
+
+ protected function url()
+ {
+ if ($this->url === null) {
+ $this->url = Url::fromRequest();
+ }
+ return $this->url;
+ }
+
+ protected function preservedUrl()
+ {
+ if ($this->preservedUrl === null) {
+ $this->preservedUrl = $this->url()->with($this->preservedParams);
+ }
+ return $this->preservedUrl;
+ }
+
+ /**
+ * Set the query to filter
+ *
+ * @param Filterable $query
+ *
+ * @return $this
+ */
+ public function setQuery(Filterable $query)
+ {
+ $this->query = $query;
+ return $this;
+ }
+
+ public function ignoreParams()
+ {
+ $this->ignoreParams = func_get_args();
+ return $this;
+ }
+
+ public function preserveParams()
+ {
+ $this->preserveParams = func_get_args();
+ return $this;
+ }
+
+ /**
+ * Get whether the filter control is visible
+ *
+ * @return bool
+ */
+ public function isVisible()
+ {
+ return $this->visible;
+ }
+
+ /**
+ * Set whether the filter control is visible
+ *
+ * @param bool $visible
+ *
+ * @return $this
+ */
+ public function setVisible($visible)
+ {
+ $this->visible = (bool) $visible;
+
+ return $this;
+ }
+
+ protected function redirectNow($url)
+ {
+ $response = Icinga::app()->getFrontController()->getResponse();
+ $response->redirectAndExit($url);
+ }
+
+ protected function mergeRootExpression($filter, $column, $sign, $expression)
+ {
+ $found = false;
+ if ($filter->isChain() && $filter->getOperatorName() === 'AND') {
+ foreach ($filter->filters() as $f) {
+ if ($f->isExpression()
+ && $f->getColumn() === $column
+ && $f->getSign() === $sign
+ ) {
+ $f->setExpression($expression);
+ $found = true;
+ break;
+ }
+ }
+ } elseif ($filter->isExpression()) {
+ if ($filter->getColumn() === $column && $filter->getSign() === $sign) {
+ $filter->setExpression($expression);
+ $found = true;
+ }
+ }
+ if (! $found) {
+ $filter = $filter->andFilter(
+ Filter::expression($column, $sign, $expression)
+ );
+ }
+ return $filter;
+ }
+
+ protected function resetSearchColumns(Filter &$filter)
+ {
+ if ($filter->isChain()) {
+ $filters = &$filter->filters();
+ if (!($empty = empty($filters))) {
+ foreach ($filters as $k => &$f) {
+ if (false === $this->resetSearchColumns($f)) {
+ unset($filters[$k]);
+ }
+ }
+ }
+ return $empty || !empty($filters);
+ }
+ return $filter->isExpression() ? !(
+ in_array($filter->getColumn(), $this->searchColumns)
+ &&
+ $filter->getSign() === '='
+ ) : true;
+ }
+
+ public function handleRequest($request)
+ {
+ $this->setUrl($request->getUrl()->without($this->ignoreParams));
+ $params = $this->url()->getParams();
+
+ $preserve = array();
+ foreach ($this->preserveParams as $key) {
+ if (null !== ($value = $params->shift($key))) {
+ $preserve[$key] = $value;
+ }
+ }
+ $this->preservedParams = $preserve;
+
+ $add = $params->shift('addFilter');
+ $remove = $params->shift('removeFilter');
+ $strip = $params->shift('stripFilter');
+ $modify = $params->shift('modifyFilter');
+
+
+
+ $search = null;
+ if ($request->isPost()) {
+ $search = $request->getPost('q');
+ }
+
+ if ($search === null) {
+ $search = $params->shift('q');
+ }
+
+ $filter = $this->getFilter();
+
+ if ($search !== null) {
+ if (strpos($search, '=') !== false) {
+ list($k, $v) = preg_split('/=/', $search);
+ $filter = $this->mergeRootExpression($filter, trim($k), '=', ltrim($v));
+ } else {
+ if ($this->searchColumns === null && $this->query instanceof FilterColumns) {
+ $this->searchColumns = $this->query->getSearchColumns($search);
+ }
+
+ if (! empty($this->searchColumns)) {
+ if (! $this->resetSearchColumns($filter)) {
+ $filter = Filter::matchAll();
+ }
+ $filters = array();
+ $search = trim($search);
+ foreach ($this->searchColumns as $searchColumn) {
+ $filters[] = Filter::expression($searchColumn, '=', "*$search*");
+ }
+ $filter = $filter->andFilter(new FilterOr($filters));
+ } else {
+ Notification::error(mt('monitoring', 'Cannot search here'));
+ return $this;
+ }
+ }
+
+ $url = Url::fromRequest()->onlyWith($this->preserveParams);
+ $urlParams = $url->getParams();
+ $url->setQueryString($filter->toQueryString());
+ foreach ($urlParams->toArray(false) as $key => $value) {
+ $url->getParams()->addEncoded($key, $value);
+ }
+
+ $this->redirectNow($url);
+ }
+
+ if ($remove) {
+ $redirect = $this->url();
+ if ($filter->getById($remove)->isRootNode()) {
+ $redirect->setQueryString('');
+ } else {
+ $filter->removeId($remove);
+ $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter');
+ }
+ $this->redirectNow($redirect->addParams($preserve));
+ }
+
+ if ($strip) {
+ $redirect = $this->url();
+ $subId = $strip . '-1';
+ if ($filter->getId() === $strip) {
+ $filter = $filter->getById($strip . '-1');
+ } else {
+ $filter->replaceById($strip, $filter->getById($strip . '-1'));
+ }
+ $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter');
+ $this->redirectNow($redirect->addParams($preserve));
+ }
+
+
+ if ($modify) {
+ if ($request->isPost()) {
+ if ($request->get('cancel') === 'Cancel') {
+ $this->redirectNow($this->preservedUrl()->without('modifyFilter'));
+ }
+ if ($request->get('formUID') === 'FilterEditor') {
+ $filter = $this->applyChanges($request->getPost());
+ $url = $this->url()->setQueryString($filter->toQueryString())->addParams($preserve);
+ $url->getParams()->add('modifyFilter');
+
+ $addFilter = $request->get('add_filter');
+ if ($addFilter !== null) {
+ $url->setParam('addFilter', $addFilter);
+ }
+
+ $removeFilter = $request->get('remove_filter');
+ if ($removeFilter !== null) {
+ $url->setParam('removeFilter', $removeFilter);
+ }
+
+ $this->redirectNow($url);
+ }
+ }
+ $this->url()->getParams()->add('modifyFilter');
+ }
+
+ if ($add) {
+ $this->addFilterToId($add);
+ }
+
+ if ($this->query !== null && $request->isGet()) {
+ $this->query->applyFilter($this->getFilter());
+ }
+
+ return $this;
+ }
+
+ protected function select($name, $list, $selected, $attributes = null)
+ {
+ $view = $this->view();
+ if ($attributes === null) {
+ $attributes = '';
+ } else {
+ $attributes = $view->propertiesToString($attributes);
+ }
+ $html = sprintf(
+ '<select name="%s"%s class="autosubmit">' . "\n",
+ $view->escape($name),
+ $attributes
+ );
+
+ foreach ($list as $k => $v) {
+ $active = '';
+ if ($k === $selected) {
+ $active = ' selected="selected"';
+ }
+ $html .= sprintf(
+ ' <option value="%s"%s>%s</option>' . "\n",
+ $view->escape($k),
+ $active,
+ $view->escape($v)
+ );
+ }
+ $html .= '</select>' . "\n\n";
+ return $html;
+ }
+
+ protected function addFilterToId($id)
+ {
+ $this->addTo = $id;
+ return $this;
+ }
+
+ protected function removeIndex($idx)
+ {
+ $this->selectedIdx = $idx;
+ return $this;
+ }
+
+ protected function removeLink(Filter $filter)
+ {
+ return "<button type='submit' name='remove_filter' value='{$filter->getId()}'>"
+ . $this->view()->icon('trash', t('Remove this part of your filter'))
+ . '</button>';
+ }
+
+ protected function addLink(Filter $filter)
+ {
+ return "<button type='submit' name='add_filter' value='{$filter->getId()}'>"
+ . $this->view()->icon('plus', t('Add another filter'))
+ . '</button>';
+ }
+
+ protected function stripLink(Filter $filter)
+ {
+ return $this->view()->qlink(
+ '',
+ $this->preservedUrl()->with('stripFilter', $filter->getId()),
+ null,
+ array(
+ 'icon' => 'minus',
+ 'title' => t('Strip this filter')
+ )
+ );
+ }
+
+ protected function cancelLink()
+ {
+ return $this->view()->qlink(
+ '',
+ $this->preservedUrl()->without('addFilter'),
+ null,
+ array(
+ 'icon' => 'cancel',
+ 'title' => t('Cancel this operation')
+ )
+ );
+ }
+
+ protected function renderFilter($filter, $level = 0)
+ {
+ if ($level === 0 && $filter->isChain() && $filter->isEmpty()) {
+ return '<ul class="datafilter"><li class="active">' . $this->renderNewFilter() . '</li></ul>';
+ }
+
+ if ($filter instanceof FilterChain) {
+ return $this->renderFilterChain($filter, $level);
+ } elseif ($filter instanceof FilterExpression) {
+ return $this->renderFilterExpression($filter);
+ } else {
+ throw new ProgrammingError('Got a Filter being neither expression nor chain');
+ }
+ }
+
+ protected function renderFilterChain(FilterChain $filter, $level)
+ {
+ $html = '<span class="handle"> </span>'
+ . $this->selectOperator($filter)
+ . $this->removeLink($filter)
+ . ($filter->count() === 1 ? $this->stripLink($filter) : '')
+ . $this->addLink($filter);
+
+ if ($filter->isEmpty() && ! $this->addTo) {
+ return $html;
+ }
+
+ $parts = array();
+ foreach ($filter->filters() as $f) {
+ $parts[] = '<li>' . $this->renderFilter($f, $level + 1) . '</li>';
+ }
+
+ if ($this->addTo && $this->addTo == $filter->getId()) {
+ $parts[] = '<li class="new-filter">' . $this->renderNewFilter() .$this->cancelLink(). '</li>';
+ }
+
+ $class = $level === 0 ? ' class="datafilter"' : '';
+ $html .= sprintf(
+ "<ul%s>\n%s</ul>\n",
+ $class,
+ implode("", $parts)
+ );
+ return $html;
+ }
+
+ protected function renderFilterExpression(FilterExpression $filter)
+ {
+ if ($this->addTo && $this->addTo === $filter->getId()) {
+ return
+ preg_replace(
+ '/ class="autosubmit"/',
+ ' class="autofocus"',
+ $this->selectOperator()
+ )
+ . '<ul><li>'
+ . $this->selectColumn($filter)
+ . $this->selectSign($filter)
+ . $this->text($filter)
+ . $this->removeLink($filter)
+ . $this->addLink($filter)
+ . '</li><li class="active">'
+ . $this->renderNewFilter() .$this->cancelLink()
+ . '</li></ul>'
+ ;
+ } else {
+ return $this->selectColumn($filter)
+ . $this->selectSign($filter)
+ . $this->text($filter)
+ . $this->removeLink($filter)
+ . $this->addLink($filter)
+ ;
+ }
+ }
+
+ protected function text(Filter $filter = null)
+ {
+ $value = $filter === null ? '' : $filter->getExpression();
+ if (is_array($value)) {
+ $value = '(' . implode('|', $value) . ')';
+ }
+ return sprintf(
+ '<input type="text" name="%s" value="%s" />',
+ $this->elementId('value', $filter),
+ $this->view()->escape($value)
+ );
+ }
+
+ protected function renderNewFilter()
+ {
+ $html = $this->selectColumn()
+ . $this->selectSign()
+ . $this->text();
+
+ return preg_replace(
+ '/ class="autosubmit"/',
+ '',
+ $html
+ );
+ }
+
+ protected function arrayForSelect($array, $flip = false)
+ {
+ $res = array();
+ foreach ($array as $k => $v) {
+ if (is_int($k)) {
+ $res[$v] = ucwords(str_replace('_', ' ', $v));
+ } elseif ($flip) {
+ $res[$v] = $k;
+ } else {
+ $res[$k] = $v;
+ }
+ }
+ // sort($res);
+ return $res;
+ }
+
+ protected function elementId($prefix, Filter $filter = null)
+ {
+ if ($filter === null) {
+ return $prefix . '_new_' . ($this->addTo ?: '0');
+ } else {
+ return $prefix . '_' . $filter->getId();
+ }
+ }
+
+ protected function selectOperator(Filter $filter = null)
+ {
+ $ops = array(
+ 'AND' => 'AND',
+ 'OR' => 'OR',
+ 'NOT' => 'NOT'
+ );
+
+ return $this->select(
+ $this->elementId('operator', $filter),
+ $ops,
+ $filter === null ? null : $filter->getOperatorName(),
+ ['class' => 'filter-operator']
+ );
+ }
+
+ protected function selectSign(Filter $filter = null)
+ {
+ $signs = array(
+ '=' => '=',
+ '!=' => '!=',
+ '>' => '>',
+ '<' => '<',
+ '>=' => '>=',
+ '<=' => '<=',
+ );
+
+ return $this->select(
+ $this->elementId('sign', $filter),
+ $signs,
+ $filter === null ? null : $filter->getSign(),
+ ['class' => 'filter-rule']
+ );
+ }
+
+ public function setColumns(array $columns = null)
+ {
+ $this->cachedColumnSelect = $columns ? $this->arrayForSelect($columns) : null;
+ return $this;
+ }
+
+ protected function selectColumn(Filter $filter = null)
+ {
+ $active = $filter === null ? null : $filter->getColumn();
+
+ if ($this->cachedColumnSelect === null && $this->query === null) {
+ return sprintf(
+ '<input type="text" name="%s" value="%s" />',
+ $this->elementId('column', $filter),
+ $this->view()->escape($active) // Escape attribute?
+ );
+ }
+
+ if ($this->cachedColumnSelect === null && $this->query instanceof FilterColumns) {
+ $this->cachedColumnSelect = $this->arrayForSelect($this->query->getFilterColumns(), true);
+ asort($this->cachedColumnSelect);
+ } elseif ($this->cachedColumnSelect === null) {
+ throw new ProgrammingError('No columns set nor does the query provide any');
+ }
+
+ $cols = $this->cachedColumnSelect;
+ if ($active && !isset($cols[$active])) {
+ $cols[$active] = str_replace('_', ' ', ucfirst(ltrim($active, '_')));
+ }
+
+ return $this->select($this->elementId('column', $filter), $cols, $active);
+ }
+
+ protected function applyChanges($changes)
+ {
+ $filter = $this->filter;
+ $pairs = array();
+ $addTo = null;
+ $add = array();
+ foreach ($changes as $k => $v) {
+ if (preg_match('/^(column|value|sign|operator)((?:_new)?)_([\d-]+)$/', $k, $m)) {
+ if ($m[2] === '_new') {
+ if ($addTo !== null && $addTo !== $m[3]) {
+ throw new \Exception('F...U');
+ }
+ $addTo = $m[3];
+ $add[$m[1]] = $v;
+ } else {
+ $pairs[$m[3]][$m[1]] = $v;
+ }
+ }
+ }
+
+ $operators = array();
+ foreach ($pairs as $id => $fs) {
+ if (array_key_exists('operator', $fs)) {
+ $operators[$id] = $fs['operator'];
+ } else {
+ $f = $filter->getById($id);
+ $f->setColumn($fs['column']);
+ if ($f->getSign() !== $fs['sign']) {
+ if ($f->isRootNode()) {
+ $filter = $f->setSign($fs['sign']);
+ } else {
+ $filter->replaceById($id, $f->setSign($fs['sign']));
+ }
+ }
+ $f->setExpression($fs['value']);
+ }
+ }
+
+ krsort($operators, SORT_NATURAL);
+ foreach ($operators as $id => $operator) {
+ $f = $filter->getById($id);
+ if ($f->getOperatorName() !== $operator) {
+ if ($f->isRootNode()) {
+ $filter = $f->setOperatorName($operator);
+ } else {
+ $filter->replaceById($id, $f->setOperatorName($operator));
+ }
+ }
+ }
+
+ if ($addTo !== null) {
+ if ($addTo === '0') {
+ $filter = Filter::expression($add['column'], $add['sign'], $add['value']);
+ } else {
+ $parent = $filter->getById($addTo);
+ $f = Filter::expression($add['column'], $add['sign'], $add['value']);
+ if (isset($add['operator'])) {
+ switch ($add['operator']) {
+ case 'AND':
+ if ($parent->isExpression()) {
+ if ($parent->isRootNode()) {
+ $filter = Filter::matchAll(clone $parent, $f);
+ } else {
+ $filter = $filter->replaceById($addTo, Filter::matchAll(clone $parent, $f));
+ }
+ } else {
+ $parent->addFilter(Filter::matchAll($f));
+ }
+ break;
+ case 'OR':
+ if ($parent->isExpression()) {
+ if ($parent->isRootNode()) {
+ $filter = Filter::matchAny(clone $parent, $f);
+ } else {
+ $filter = $filter->replaceById($addTo, Filter::matchAny(clone $parent, $f));
+ }
+ } else {
+ $parent->addFilter(Filter::matchAny($f));
+ }
+ break;
+ case 'NOT':
+ if ($parent->isExpression()) {
+ if ($parent->isRootNode()) {
+ $filter = Filter::not(Filter::matchAll($parent, $f));
+ } else {
+ $filter = $filter->replaceById($addTo, Filter::not(Filter::matchAll($parent, $f)));
+ }
+ } else {
+ $parent->addFilter(Filter::not($f));
+ }
+ break;
+ }
+ } else {
+ $parent->addFilter($f);
+ }
+ }
+ }
+
+ return $filter;
+ }
+
+ public function renderSearch()
+ {
+ $preservedUrl = $this->preservedUrl();
+
+ $html = ' <form method="post" class="search inline" action="'
+ . $preservedUrl
+ . '"><input type="text" name="q" class="search search-input" value="" placeholder="'
+ . t('Search...')
+ . '" /></form>';
+
+ if ($this->filter->isEmpty()) {
+ $title = t('Filter this list');
+ } else {
+ $title = t('Modify this filter');
+ if (! $this->filter->isEmpty()) {
+ $title .= ': ' . $this->view()->escape($this->filter);
+ }
+ }
+
+ return $html
+ . '<a href="'
+ . $preservedUrl->with('modifyFilter', ! $preservedUrl->getParam('modifyFilter'))
+ . '" aria-label="'
+ . $title
+ . '" title="'
+ . $title
+ . '">'
+ . '<i aria-hidden="true" class="icon-filter"></i>'
+ . '</a>';
+ }
+
+ public function render()
+ {
+ if (! $this->visible) {
+ return '';
+ }
+ if (! $this->preservedUrl()->getParam('modifyFilter')) {
+ return '<div class="filter icinga-controls">'
+ . $this->renderSearch()
+ . $this->view()->escape($this->shorten($this->filter, 50))
+ . '</div>';
+ }
+ return '<div class="filter icinga-controls">'
+ . $this->renderSearch()
+ . '<form action="'
+ . Url::fromRequest()
+ . '" class="editor" method="POST">'
+ . '<input type="submit" name="submit" value="Apply" hidden/>'
+ . '<ul class="tree"><li>'
+ . $this->renderFilter($this->filter)
+ . '</li></ul>'
+ . '<div class="buttons">'
+ . '<input type="submit" name="cancel" value="Cancel" class="button btn-cancel" />'
+ . '<input type="submit" name="submit" value="Apply" class="button btn-primary"/>'
+ . '</div>'
+ . '<input type="hidden" name="formUID" value="FilterEditor">'
+ . '</form>'
+ . '</div>';
+ }
+
+ protected function shorten($string, $length)
+ {
+ if (strlen($string) > $length) {
+ return substr($string, 0, $length) . '...';
+ }
+ return $string;
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return 'ERROR in FilterEditor: ' . $e->getMessage();
+ }
+ }
+}
diff --git a/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php
new file mode 100644
index 0000000..007a730
--- /dev/null
+++ b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php
@@ -0,0 +1,92 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget\ItemList;
+
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+
+class MigrationFileListItem extends BaseListItem
+{
+ use Translation;
+
+ /** @var DbMigrationStep Just for type hint */
+ protected $item;
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ if ($this->item->getLastState()) {
+ $visual->getAttributes()->add('class', 'upgrade-failed');
+ $visual->addHtml(new Icon('circle-xmark'));
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $scriptPath = $this->item->getScriptPath();
+ /** @var string $parentDirs */
+ $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema'));
+ $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1);
+
+ $title->addHtml(
+ new HtmlElement('span', null, Text::create($parentDirs)),
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'version']),
+ Text::create($this->item->getVersion() . '.sql')
+ )
+ );
+
+ if ($this->item->getLastState()) {
+ $title->addHtml(
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'upgrade-failed']),
+ Text::create($this->translate('Upgrade failed'))
+ )
+ );
+ }
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->addHtml($this->createTitle());
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ if ($this->item->getDescription()) {
+ $caption->addHtml(Text::create($this->item->getDescription()));
+ } else {
+ $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.'))));
+ }
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ if ($this->item->getLastState()) {
+ $footer->addHtml(
+ new HtmlElement(
+ 'section',
+ Attributes::create(['class' => 'caption']),
+ new HtmlElement('pre', null, new HtmlString(Html::escape($this->item->getLastState())))
+ )
+ );
+ }
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml($this->createHeader(), $this->createCaption());
+ }
+}
diff --git a/library/Icinga/Web/Widget/ItemList/MigrationList.php b/library/Icinga/Web/Widget/ItemList/MigrationList.php
new file mode 100644
index 0000000..43699d3
--- /dev/null
+++ b/library/Icinga/Web/Widget/ItemList/MigrationList.php
@@ -0,0 +1,133 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget\ItemList;
+
+use Generator;
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Application\MigrationManager;
+use Icinga\Forms\MigrationForm;
+use ipl\I18n\Translation;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Widget\EmptyStateBar;
+
+class MigrationList extends BaseItemList
+{
+ use Translation;
+
+ protected $baseAttributes = ['class' => 'item-list'];
+
+ /** @var Generator<DbMigrationHook> */
+ protected $data;
+
+ /** @var ?MigrationForm */
+ protected $migrationForm;
+
+ /** @var bool Whether to render minimal migration list items */
+ protected $minimal = true;
+
+ /**
+ * Create a new migration list
+ *
+ * @param Generator<DbMigrationHook>|array<DbMigrationStep|DbMigrationHook> $data
+ *
+ * @param ?MigrationForm $form
+ */
+ public function __construct($data, MigrationForm $form = null)
+ {
+ parent::__construct($data);
+
+ $this->migrationForm = $form;
+ }
+
+ /**
+ * Set whether to render minimal migration list items
+ *
+ * @param bool $minimal
+ *
+ * @return $this
+ */
+ public function setMinimal(bool $minimal): self
+ {
+ $this->minimal = $minimal;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to render minimal migration list items
+ *
+ * @return bool
+ */
+ public function isMinimal(): bool
+ {
+ return $this->minimal;
+ }
+
+ protected function getItemClass(): string
+ {
+ if ($this->isMinimal()) {
+ return MigrationListItem::class;
+ }
+
+ return MigrationFileListItem::class;
+ }
+
+ protected function assemble(): void
+ {
+ $itemClass = $this->getItemClass();
+ if (! $this->isMinimal()) {
+ $this->getAttributes()->add('class', 'file-list');
+ }
+
+ /** @var DbMigrationHook $data */
+ foreach ($this->data as $data) {
+ /** @var MigrationFileListItem|MigrationListItem $item */
+ $item = new $itemClass($data, $this);
+ if ($item instanceof MigrationListItem && $this->migrationForm) {
+ $migrateButton = $this->migrationForm->createElement(
+ 'submit',
+ sprintf('migrate-%s', $data->getModuleName()),
+ [
+ 'required' => false,
+ 'label' => $this->translate('Migrate'),
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'Migrate %d pending migration',
+ 'Migrate all %d pending migrations',
+ $data->count()
+ ),
+ $data->count()
+ )
+ ]
+ );
+
+ $mm = MigrationManager::instance();
+ if ($data->isModule() && $mm->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
+ $migrateButton->getAttributes()
+ ->set('disabled', true)
+ ->set(
+ 'title',
+ $this->translate(
+ 'Please apply all the pending migrations of Icinga Web first or use the apply all'
+ . ' button instead.'
+ )
+ );
+ }
+
+ $this->migrationForm->registerElement($migrateButton);
+
+ $item->setMigrateButton($migrateButton);
+ }
+
+ $this->addHtml($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar(t('No items found.')));
+ }
+ }
+}
diff --git a/library/Icinga/Web/Widget/ItemList/MigrationListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php
new file mode 100644
index 0000000..284ce4c
--- /dev/null
+++ b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php
@@ -0,0 +1,151 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget\ItemList;
+
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use Icinga\Application\Hook\DbMigrationHook;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\FormattedString;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Url;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use LogicException;
+
+class MigrationListItem extends BaseListItem
+{
+ use Translation;
+
+ /** @var ?FormElement */
+ protected $migrateButton;
+
+ /** @var DbMigrationHook Just for type hint */
+ protected $item;
+
+ /**
+ * Set a migration form of this list item
+ *
+ * @param FormElement $migrateButton
+ *
+ * @return $this
+ */
+ public function setMigrateButton(FormElement $migrateButton): self
+ {
+ $this->migrateButton = $migrateButton;
+
+ return $this;
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ FormattedString::create(
+ t('%s ', '<name>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->item->getName())
+ )
+ );
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ if ($this->migrateButton === null) {
+ throw new LogicException('Please set the migrate submit button beforehand');
+ }
+
+ $header->addHtml($this->createTitle());
+ $header->addHtml($this->migrateButton);
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ $migrations = $this->item->getMigrations();
+ /** @var DbMigrationStep $migration */
+ $migration = array_shift($migrations);
+ if ($migration->getLastState()) {
+ if ($migration->getDescription()) {
+ $caption->addHtml(Text::create($migration->getDescription()));
+ } else {
+ $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.'))));
+ }
+
+ $scriptPath = $migration->getScriptPath();
+ /** @var string $parentDirs */
+ $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema'));
+ $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1);
+
+ $title = new HtmlElement('div', Attributes::create(['class' => 'title']));
+ $title->addHtml(
+ new HtmlElement('span', null, Text::create($parentDirs)),
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'version']),
+ Text::create($migration->getVersion() . '.sql')
+ ),
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'upgrade-failed']),
+ Text::create($this->translate('Upgrade failed'))
+ )
+ );
+
+ $error = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'data-visible-height' => '58',
+ ]));
+ $error->addHtml(new HtmlElement('pre', null, new HtmlString(Html::escape($migration->getLastState()))));
+
+ $errorSection = new HtmlElement('div', Attributes::create(['class' => 'errors-section',]));
+ $errorSection->addHtml(
+ new HtmlElement('header', null, new Icon('circle-xmark', ['class' => 'status-icon']), $title),
+ $caption,
+ $error
+ );
+
+ $caption->prependWrapper($errorSection);
+ }
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ $footer->addHtml((new MigrationList($this->item->getLatestMigrations(3)))->setMinimal(false));
+ if ($this->item->count() > 3) {
+ $footer->addHtml(
+ new Link(
+ sprintf($this->translate('Show all %d migrations'), $this->item->count()),
+ Url::fromPath(
+ 'migrations/migration',
+ [DbMigrationHook::MIGRATION_PARAM => $this->item->getModuleName()]
+ ),
+ [
+ 'data-base-target' => '_next',
+ 'class' => 'show-more'
+ ]
+ )
+ );
+ }
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml($this->createHeader());
+ $caption = $this->createCaption();
+ if (! $caption->isEmpty()) {
+ $main->addHtml($caption);
+ }
+
+ $footer = $this->createFooter();
+ if ($footer) {
+ $main->addHtml($footer);
+ }
+ }
+}
diff --git a/library/Icinga/Web/Widget/Limiter.php b/library/Icinga/Web/Widget/Limiter.php
new file mode 100644
index 0000000..d127aca
--- /dev/null
+++ b/library/Icinga/Web/Widget/Limiter.php
@@ -0,0 +1,54 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Forms\Control\LimiterControlForm;
+
+/**
+ * Limiter control widget
+ */
+class Limiter extends AbstractWidget
+{
+ /**
+ * Default limit for this instance
+ *
+ * @var int|null
+ */
+ protected $defaultLimit;
+
+ /**
+ * Get the default limit
+ *
+ * @return int|null
+ */
+ public function getDefaultLimit()
+ {
+ return $this->defaultLimit;
+ }
+
+ /**
+ * Set the default limit
+ *
+ * @param int $defaultLimit
+ *
+ * @return $this
+ */
+ public function setDefaultLimit($defaultLimit)
+ {
+ $this->defaultLimit = (int) $defaultLimit;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render()
+ {
+ $control = new LimiterControlForm();
+ $control
+ ->setDefaultLimit($this->defaultLimit)
+ ->handleRequest();
+ return (string)$control;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Paginator.php b/library/Icinga/Web/Widget/Paginator.php
new file mode 100644
index 0000000..5f3ef04
--- /dev/null
+++ b/library/Icinga/Web/Widget/Paginator.php
@@ -0,0 +1,167 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Data\Paginatable;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Paginator
+ */
+class Paginator extends AbstractWidget
+{
+ /**
+ * The query the paginator widget is created for
+ *
+ * @var Paginatable
+ */
+ protected $query;
+
+ /**
+ * The view script in use
+ *
+ * @var string|array
+ */
+ protected $viewScript = array('mixedPagination.phtml', 'default');
+
+ /**
+ * Set the query to create the paginator widget for
+ *
+ * @param Paginatable $query
+ *
+ * @return $this
+ */
+ public function setQuery(Paginatable $query)
+ {
+ $this->query = $query;
+ return $this;
+ }
+
+ /**
+ * Set the view script to use
+ *
+ * @param string|array $script
+ *
+ * @return $this
+ */
+ public function setViewScript($script)
+ {
+ $this->viewScript = $script;
+ return $this;
+ }
+
+ /**
+ * Render this paginator
+ */
+ public function render()
+ {
+ if ($this->query === null) {
+ throw new ProgrammingError('Need a query to create the paginator widget for');
+ }
+
+ $itemCountPerPage = $this->query->getLimit();
+ if (! $itemCountPerPage) {
+ return ''; // No pagination required
+ }
+
+ $totalItemCount = count($this->query);
+ $pageCount = (int) ceil($totalItemCount / $itemCountPerPage);
+ $currentPage = $this->query->hasOffset() ? ($this->query->getOffset() / $itemCountPerPage) + 1 : 1;
+ $pagesInRange = $this->getPages($pageCount, $currentPage);
+ $variables = array(
+ 'totalItemCount' => $totalItemCount,
+ 'pageCount' => $pageCount,
+ 'itemCountPerPage' => $itemCountPerPage,
+ 'first' => 1,
+ 'current' => $currentPage,
+ 'last' => $pageCount,
+ 'pagesInRange' => $pagesInRange,
+ 'firstPageInRange' => min($pagesInRange),
+ 'lastPageInRange' => max($pagesInRange)
+ );
+
+ if ($currentPage > 1) {
+ $variables['previous'] = $currentPage - 1;
+ }
+
+ if ($currentPage < $pageCount) {
+ $variables['next'] = $currentPage + 1;
+ }
+
+ if (is_array($this->viewScript)) {
+ if ($this->viewScript[1] !== null) {
+ return $this->view()->partial($this->viewScript[0], $this->viewScript[1], $variables);
+ }
+
+ return $this->view()->partial($this->viewScript[0], $variables);
+ }
+
+ return $this->view()->partial($this->viewScript, $variables);
+ }
+
+ /**
+ * Returns an array of "local" pages given the page count and current page number
+ *
+ * @return array
+ */
+ protected function getPages($pageCount, $currentPage)
+ {
+ $range = array();
+
+ if ($pageCount < 10) {
+ // Show all pages if we have less than 10
+ for ($i = 1; $i < 10; $i++) {
+ if ($i > $pageCount) {
+ break;
+ }
+
+ $range[$i] = $i;
+ }
+ } else {
+ // More than 10 pages:
+ foreach (array(1, 2) as $i) {
+ $range[$i] = $i;
+ }
+
+ if ($currentPage < 6) {
+ // We are on page 1-5 from
+ for ($i = 1; $i <= 7; $i++) {
+ $range[$i] = $i;
+ }
+ } else {
+ // Current page > 5
+ $range[] = '...';
+
+ if (($pageCount - $currentPage) < 5) {
+ // Less than 5 pages left
+ $start = 5 - ($pageCount - $currentPage);
+ } else {
+ $start = 1;
+ }
+
+ for ($i = $currentPage - $start; $i < ($currentPage + (4 - $start)); $i++) {
+ if ($i > $pageCount) {
+ break;
+ }
+
+ $range[$i] = $i;
+ }
+ }
+
+ if ($currentPage < ($pageCount - 2)) {
+ $range[] = '...';
+ }
+
+ foreach (array($pageCount - 1, $pageCount) as $i) {
+ $range[$i] = $i;
+ }
+ }
+
+ if (empty($range)) {
+ $range[] = 1;
+ }
+
+ return $range;
+ }
+}
diff --git a/library/Icinga/Web/Widget/SearchDashboard.php b/library/Icinga/Web/Widget/SearchDashboard.php
new file mode 100644
index 0000000..1ce4c46
--- /dev/null
+++ b/library/Icinga/Web/Widget/SearchDashboard.php
@@ -0,0 +1,111 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Zend_Controller_Action_Exception;
+use Icinga\Application\Icinga;
+use Icinga\Web\Url;
+
+/**
+ * Class SearchDashboard display multiple search views on a single search page
+ */
+class SearchDashboard extends Dashboard
+{
+ /**
+ * Name for the search pane
+ *
+ * @var string
+ */
+ const SEARCH_PANE = 'search';
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTabs()
+ {
+ if ($this->tabs === null) {
+ $this->tabs = new Tabs();
+ $this->tabs->add(
+ 'search',
+ array(
+ 'title' => t('Show Search', 'dashboard.pane.tooltip'),
+ 'label' => t('Search'),
+ 'url' => Url::fromRequest()
+ )
+ );
+ }
+ return $this->tabs;
+ }
+
+ /**
+ * Load all available search dashlets from modules
+ *
+ * @param string $searchString
+ *
+ * @return $this
+ */
+ public function search($searchString = '')
+ {
+ $pane = $this->createPane(self::SEARCH_PANE)->getPane(self::SEARCH_PANE)->setTitle(t('Search'));
+ $this->activate(self::SEARCH_PANE);
+
+ $manager = Icinga::app()->getModuleManager();
+ $searchUrls = array();
+
+ foreach ($manager->getLoadedModules() as $module) {
+ if ($this->getUser()->can($manager::MODULE_PERMISSION_NS . $module->getName())) {
+ $moduleSearchUrls = $module->getSearchUrls();
+ if (! empty($moduleSearchUrls)) {
+ if ($searchString === '') {
+ $pane->add(t('Ready to search'), 'search/hint');
+ return $this;
+ }
+ $searchUrls = array_merge($searchUrls, $moduleSearchUrls);
+ }
+ }
+ }
+
+ usort($searchUrls, array($this, 'compareSearchUrls'));
+
+ foreach (array_reverse($searchUrls) as $searchUrl) {
+ $pane->createDashlet(
+ $searchUrl->title . ': ' . $searchString,
+ Url::fromPath($searchUrl->url, array('q' => $searchString))
+ )->setProgressLabel(t('Searching'));
+ }
+
+ return $this;
+ }
+
+ /**
+ * Renders the output
+ *
+ * @return string
+ *
+ * @throws Zend_Controller_Action_Exception
+ */
+ public function render()
+ {
+ if (! $this->getPane(self::SEARCH_PANE)->hasDashlets()) {
+ throw new Zend_Controller_Action_Exception(t('Page not found'), 404);
+ }
+ return parent::render();
+ }
+
+ /**
+ * Compare search URLs based on their priority
+ *
+ * @param object $a
+ * @param object $b
+ *
+ * @return int
+ */
+ private function compareSearchUrls($a, $b)
+ {
+ if ($a->priority === $b->priority) {
+ return 0;
+ }
+ return ($a->priority < $b->priority) ? -1 : 1;
+ }
+}
diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php
new file mode 100644
index 0000000..470518c
--- /dev/null
+++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php
@@ -0,0 +1,200 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Icinga;
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\Control\SearchBar\Suggestions;
+use ipl\Web\Url;
+
+class SingleValueSearchControl extends Form
+{
+ /** @var string */
+ const DEFAULT_SEARCH_PARAMETER = 'q';
+
+ protected $defaultAttributes = ['class' => 'icinga-controls inline'];
+
+ /** @var string */
+ protected $searchParameter = self::DEFAULT_SEARCH_PARAMETER;
+
+ /** @var string */
+ protected $inputLabel;
+
+ /** @var string */
+ protected $submitLabel;
+
+ /** @var Url */
+ protected $suggestionUrl;
+
+ /** @var array */
+ protected $metaDataNames;
+
+ /**
+ * Set the search parameter to use
+ *
+ * @param string $name
+ * @return $this
+ */
+ public function setSearchParameter($name)
+ {
+ $this->searchParameter = $name;
+
+ return $this;
+ }
+
+ /**
+ * Set the input's label
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setInputLabel($label)
+ {
+ $this->inputLabel = $label;
+
+ return $this;
+ }
+
+ /**
+ * Set the submit button's label
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+
+ return $this;
+ }
+
+ /**
+ * Set the suggestion url
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setSuggestionUrl(Url $url)
+ {
+ $this->suggestionUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Set names for which hidden meta data elements should be created
+ *
+ * @param string ...$names
+ *
+ * @return $this
+ */
+ public function setMetaDataNames(...$names)
+ {
+ $this->metaDataNames = $names;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $suggestionsId = Icinga::app()->getRequest()->protectId('single-value-suggestions');
+
+ $this->addElement(
+ 'text',
+ $this->searchParameter,
+ [
+ 'required' => true,
+ 'minlength' => 1,
+ 'autocomplete' => 'off',
+ 'class' => 'search',
+ 'data-enrichment-type' => 'completion',
+ 'data-term-suggestions' => '#' . $suggestionsId,
+ 'data-suggest-url' => $this->suggestionUrl,
+ 'placeholder' => $this->inputLabel
+ ]
+ );
+
+ if (! empty($this->metaDataNames)) {
+ $fieldset = new HtmlElement('fieldset');
+ foreach ($this->metaDataNames as $name) {
+ $hiddenElement = $this->createElement('hidden', $this->searchParameter . '-' . $name);
+ $this->registerElement($hiddenElement);
+ $fieldset->addHtml($hiddenElement);
+ }
+
+ $this->getElement($this->searchParameter)->prependWrapper($fieldset);
+ }
+
+ $this->addElement(
+ 'submit',
+ 'btn_sumit',
+ [
+ 'label' => $this->submitLabel,
+ 'class' => 'btn-primary'
+ ]
+ );
+
+ $this->add(HtmlElement::create('div', [
+ 'id' => $suggestionsId,
+ 'class' => 'search-suggestions'
+ ]));
+ }
+
+ /**
+ * Create a list of search suggestions based on the given groups
+ *
+ * @param array $groups
+ *
+ * @return HtmlElement
+ */
+ public static function createSuggestions(array $groups)
+ {
+ $ul = new HtmlElement('ul');
+ foreach ($groups as list($name, $entries)) {
+ if ($name) {
+ if ($entries === false) {
+ $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [
+ HtmlElement::create('em', null, t('Can\'t search:')),
+ $name
+ ]));
+ continue;
+ } elseif (empty($entries)) {
+ $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [
+ HtmlElement::create('em', null, t('No results:')),
+ $name
+ ]));
+ continue;
+ } else {
+ $ul->addHtml(
+ HtmlElement::create('li', ['class' => Suggestions::SUGGESTION_TITLE_CLASS], $name)
+ );
+ }
+ }
+
+ $index = 0;
+ foreach ($entries as list($label, $metaData)) {
+ $attributes = [
+ 'value' => $label,
+ 'type' => 'button',
+ 'tabindex' => -1
+ ];
+ foreach ($metaData as $key => $value) {
+ $attributes['data-' . $key] = $value;
+ }
+
+ $liAtrs = ['class' => $index === 0 ? 'default' : null];
+ $ul->addHtml(new HtmlElement('li', Attributes::create($liAtrs), new InputElement(null, $attributes)));
+ $index++;
+ }
+ }
+
+ return $ul;
+ }
+}
diff --git a/library/Icinga/Web/Widget/SortBox.php b/library/Icinga/Web/Widget/SortBox.php
new file mode 100644
index 0000000..72b6f58
--- /dev/null
+++ b/library/Icinga/Web/Widget/SortBox.php
@@ -0,0 +1,260 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Application\Icinga;
+use Icinga\Data\Sortable;
+use Icinga\Data\SortRules;
+use Icinga\Web\Form;
+use Icinga\Web\Request;
+
+/**
+ * SortBox widget
+ *
+ * The "SortBox" Widget allows you to create a generic sort input for sortable views. It automatically creates a select
+ * box with all sort options and a dropbox with the sort direction. It also handles automatic submission of sorting
+ * changes and draws an additional submit button when JavaScript is disabled.
+ *
+ * The constructor takes a string for the component name and an array containing the select options, where the key is
+ * the value to be submitted and the value is the label that will be shown. You then should call setRequest in order
+ * to make sure the form is correctly populated when a request with a sort parameter is being made.
+ *
+ * Call setQuery in case you'll do not want to handle URL parameters manually, but to automatically apply the user's
+ * chosen sort rules on the given sortable query. This will also allow the SortBox to display the user the correct
+ * default sort rules if the given query provides already some sort rules.
+ */
+class SortBox extends AbstractWidget
+{
+ /**
+ * An array containing all sort columns with their associated labels
+ *
+ * @var array
+ */
+ protected $sortFields;
+
+ /**
+ * An array containing default sort directions for specific columns
+ *
+ * The first entry will be used as default sort column.
+ *
+ * @var array
+ */
+ protected $sortDefaults;
+
+ /**
+ * The name used to uniquely identfy the forms being created
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The request to fetch sort rules from
+ *
+ * @var Request
+ */
+ protected $request;
+
+ /**
+ * The query to apply sort rules on
+ *
+ * @var Sortable
+ */
+ protected $query;
+
+ /**
+ * Create a SortBox with the entries from $sortFields
+ *
+ * @param string $name The name for the SortBox
+ * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox
+ * @param array $sortDefaults An array containing default sort directions for specific columns
+ */
+ public function __construct($name, array $sortFields, array $sortDefaults = null)
+ {
+ $this->name = $name;
+ $this->sortFields = $sortFields;
+ $this->sortDefaults = $sortDefaults;
+ }
+
+ /**
+ * Create a SortBox
+ *
+ * @param string $name The name for the SortBox
+ * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox
+ * @param array $sortDefaults An array containing default sort directions for specific columns
+ *
+ * @return SortBox
+ */
+ public static function create($name, array $sortFields, array $sortDefaults = null)
+ {
+ return new static($name, $sortFields, $sortDefaults);
+ }
+
+ /**
+ * Set the request to fetch sort rules from
+ *
+ * @param Request $request
+ *
+ * @return $this
+ */
+ public function setRequest($request)
+ {
+ $this->request = $request;
+ return $this;
+ }
+
+ /**
+ * Set the query to apply sort rules on
+ *
+ * @param Sortable $query
+ *
+ * @return $this
+ */
+ public function setQuery(Sortable $query)
+ {
+ $this->query = $query;
+ return $this;
+ }
+
+ /**
+ * Return the default sort rule for the query
+ *
+ * @param string $column An optional column
+ *
+ * @return array An array of two values: $column, $direction
+ */
+ protected function getSortDefaults($column = null)
+ {
+ $direction = null;
+ if (! empty($this->sortDefaults) && ($column === null || isset($this->sortDefaults[$column]))) {
+ if ($column === null) {
+ reset($this->sortDefaults);
+ $column = key($this->sortDefaults);
+ }
+
+ $direction = $this->sortDefaults[$column];
+ } elseif ($this->query !== null && $this->query instanceof SortRules) {
+ $sortRules = $this->query->getSortRules();
+ if ($column === null) {
+ $column = key($sortRules);
+ }
+
+ if ($column !== null && isset($sortRules[$column]['order'])) {
+ $direction = strtoupper($sortRules[$column]['order']) === Sortable::SORT_DESC ? 'desc' : 'asc';
+ }
+ } elseif ($column === null) {
+ reset($this->sortFields);
+ $column = key($this->sortFields);
+ }
+
+ return array($column, $direction);
+ }
+
+ /**
+ * Apply the sort rules from the given or current request on the query
+ *
+ * @param Request $request
+ *
+ * @return $this
+ */
+ public function handleRequest(Request $request = null)
+ {
+ if ($this->query !== null) {
+ if ($request === null) {
+ $request = Icinga::app()->getRequest();
+ }
+
+ if (! ($sort = $request->getParam('sort'))) {
+ list($sort, $dir) = $this->getSortDefaults();
+ } else {
+ list($_, $dir) = $this->getSortDefaults($sort);
+ }
+
+ $this->query->order($sort, $request->getParam('dir', $dir));
+ }
+
+ return $this;
+ }
+
+ /**
+ * Render this SortBox as HTML
+ *
+ * @return string
+ */
+ public function render()
+ {
+ $columnForm = new Form();
+ $columnForm->setTokenDisabled();
+ $columnForm->setName($this->name . '-column');
+ $columnForm->setAttrib('class', 'icinga-controls inline');
+ $columnForm->addElement(
+ 'select',
+ 'sort',
+ array(
+ 'autosubmit' => true,
+ 'label' => $this->view()->translate('Sort by'),
+ 'multiOptions' => $this->sortFields,
+ 'decorators' => array(
+ array('ViewHelper'),
+ array('Label')
+ )
+ )
+ );
+
+ $column = null;
+ if ($this->request) {
+ $url = $this->request->getUrl();
+ if ($url->hasParam('sort')) {
+ $column = $url->getParam('sort');
+
+ if ($url->hasParam('dir')) {
+ $direction = $url->getParam('dir');
+ } else {
+ list($_, $direction) = $this->getSortDefaults($column);
+ }
+ } elseif ($url->hasParam('dir')) {
+ $direction = $url->getParam('dir');
+ list($column, $_) = $this->getSortDefaults();
+ }
+ }
+
+ if ($column === null) {
+ list($column, $direction) = $this->getSortDefaults();
+ }
+
+ // TODO(el): ToggleButton :)
+ $toggle = array('asc' => 'sort-name-down', 'desc' => 'sort-name-up');
+ unset($toggle[isset($direction) ? strtolower($direction) : 'asc']);
+ $newDirection = key($toggle);
+ $icon = current($toggle);
+
+ $orderForm = new Form();
+ $orderForm->setTokenDisabled();
+ $orderForm->setName($this->name . '-order');
+ $orderForm->setAttrib('class', 'inline sort-direction-control');
+ $orderForm->addElement(
+ 'hidden',
+ 'dir'
+ );
+ $orderForm->addElement(
+ 'button',
+ 'btn_submit',
+ array(
+ 'ignore' => true,
+ 'type' => 'submit',
+ 'label' => $this->view()->icon($icon),
+ 'decorators' => array('ViewHelper'),
+ 'escape' => false,
+ 'class' => 'link-button spinner',
+ 'value' => 'submit',
+ 'title' => t('Change sort direction'),
+ )
+ );
+
+
+ $columnForm->populate(array('sort' => $column));
+ $orderForm->populate(array('dir' => $newDirection));
+ return '<div class="sort-control">' . $columnForm . $orderForm . '</div>';
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tab.php b/library/Icinga/Web/Widget/Tab.php
new file mode 100644
index 0000000..a367f00
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tab.php
@@ -0,0 +1,323 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Web\Url;
+
+/**
+ * A single tab, usually used through the tabs widget
+ *
+ * Will generate an &lt;li&gt; list item, with an optional link and icon
+ *
+ * @property string $name Tab identifier
+ * @property string $title Tab title
+ * @property string $icon Icon URL, preferrably relative to the Icinga
+ * base URL
+ * @property string|URL $url Action URL, preferrably relative to the Icinga
+ * base URL
+ * @property string $urlParams Action URL Parameters
+ *
+ */
+class Tab extends AbstractWidget
+{
+ /**
+ * Whether this tab is currently active
+ *
+ * @var bool
+ */
+ private $active = false;
+
+ /**
+ * Default values for widget properties
+ *
+ * @var array
+ */
+ private $name = null;
+
+ /**
+ * The title displayed for this tab
+ *
+ * @var string
+ */
+ private $title = '';
+
+ /**
+ * The label displayed for this tab
+ *
+ * @var string
+ */
+ private $label = '';
+
+ /**
+ * The Url this tab points to
+ *
+ * @var Url|null
+ */
+ private $url = null;
+
+ /**
+ * The parameters for this tab's Url
+ *
+ * @var array
+ */
+ private $urlParams = array();
+
+ /**
+ * The icon image to use for this tab or null if none
+ *
+ * @var string|null
+ */
+ private $icon = null;
+
+ /**
+ * The icon class to use if $icon is null
+ *
+ * @var string|null
+ */
+ private $iconCls = null;
+
+ /**
+ * Additional a tag attributes
+ *
+ * @var array
+ */
+ private $tagParams;
+
+ /**
+ * Whether to open the link target on a new page
+ *
+ * @var boolean
+ */
+ private $targetBlank = false;
+
+ /**
+ * Data base target that determines if the link will be opened in a side-bar or in the main container
+ *
+ * @var null
+ */
+ private $baseTarget = null;
+
+ /**
+ * Sets an icon image for this tab
+ *
+ * @param string $icon The url of the image to use
+ */
+ public function setIcon($icon)
+ {
+ if (is_string($icon) && strpos($icon, '.') !== false) {
+ $icon = Url::fromPath($icon);
+ }
+ $this->icon = $icon;
+ }
+
+ /**
+ * Set's an icon class that will be used in an <i> tag if no icon image is set
+ *
+ * @param string $iconCls The CSS class of the icon to use
+ */
+ public function setIconCls($iconCls)
+ {
+ $this->iconCls = $iconCls;
+ }
+
+ /**
+ * @param mixed $name
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the tab label
+ *
+ * @param string $label
+ */
+ public function setLabel($label)
+ {
+ $this->label = $label;
+ }
+
+ /**
+ * Get the tab label
+ *
+ * @return string
+ */
+ public function getLabel()
+ {
+ if (! $this->label) {
+ return $this->title;
+ }
+
+ return $this->label;
+ }
+
+ /**
+ * @param mixed $title
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ }
+
+ /**
+ * Set the Url this tab points to
+ *
+ * @param string|Url $url The Url to use for this tab
+ */
+ public function setUrl($url)
+ {
+ if (is_string($url)) {
+ $url = Url::fromPath($url);
+ }
+ $this->url = $url;
+ }
+
+ /**
+ * Get the tab's target URL
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the parameters to be set for this tabs Url
+ *
+ * @param array $url The Url parameters to set
+ */
+ public function setUrlParams(array $urlParams)
+ {
+ $this->urlParams = $urlParams;
+ }
+
+ /**
+ * Set additional a tag attributes
+ *
+ * @param array $tagParams
+ */
+ public function setTagParams(array $tagParams)
+ {
+ $this->tagParams = $tagParams;
+ }
+
+ public function setTargetBlank($value = true)
+ {
+ $this->targetBlank = $value;
+ }
+
+ public function setBaseTarget($value)
+ {
+ $this->baseTarget = $value;
+ }
+
+ /**
+ * Create a new Tab with the given properties
+ *
+ * Allowed properties are all properties for which a setter exists
+ *
+ * @param array $properties An array of properties
+ */
+ public function __construct(array $properties = array())
+ {
+ foreach ($properties as $name => $value) {
+ $setter = 'set' . ucfirst($name);
+ if (method_exists($this, $setter)) {
+ $this->$setter($value);
+ }
+ }
+ }
+
+ /**
+ * Set this tab active (default) or inactive
+ *
+ * This is usually done through the tabs container widget, therefore it
+ * is not a good idea to directly call this function
+ *
+ * @param bool $active Whether the tab should be active
+ *
+ * @return $this
+ */
+ public function setActive($active = true)
+ {
+ $this->active = (bool) $active;
+ return $this;
+ }
+
+ /**
+ * @see Widget::render()
+ */
+ public function render()
+ {
+ $view = $this->view();
+ $classes = array();
+ if ($this->active) {
+ $classes[] = 'active';
+ }
+
+ $caption = $view->escape($this->getLabel());
+ $tagParams = $this->tagParams;
+ if ($this->targetBlank) {
+ // add warning to links that open in new tabs to improve accessibility, as recommended by WCAG20 G201
+ $caption .= '<span class="info-box display-on-hover"> opens in new window </span>';
+ $tagParams['target'] ='_blank';
+ }
+
+ if ($this->title) {
+ if ($tagParams !== null) {
+ $tagParams['title'] = $this->title;
+ $tagParams['aria-label'] = $this->title;
+ } else {
+ $tagParams = array(
+ 'title' => $this->title,
+ 'aria-label' => $this->title
+ );
+ }
+ }
+
+ if ($this->baseTarget !== null) {
+ $tagParams['data-base-target'] = $this->baseTarget;
+ }
+
+ if ($this->icon !== null) {
+ if (strpos($this->icon, '.') === false) {
+ $caption = $view->icon($this->icon) . $caption;
+ } else {
+ $caption = $view->img($this->icon, null, array('class' => 'icon')) . $caption;
+ }
+ }
+
+ if ($this->url !== null) {
+ $this->url->overwriteParams($this->urlParams);
+
+ if ($tagParams !== null) {
+ $params = $view->propertiesToString($tagParams);
+ } else {
+ $params = '';
+ }
+
+ $tab = sprintf(
+ '<a href="%s"%s>%s</a>',
+ $this->view()->escape($this->url->getAbsoluteUrl()),
+ $params,
+ $caption
+ );
+ } else {
+ $tab = $caption;
+ }
+
+ $class = empty($classes) ? '' : sprintf(' class="%s"', implode(' ', $classes));
+ return '<li ' . $class . '>' . $tab . "</li>\n";
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardAction.php b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php
new file mode 100644
index 0000000..a3e6c43
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php
@@ -0,0 +1,35 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension that allows to add the current URL to a dashboard
+ *
+ * Displayed as a dropdown field in the tabs
+ */
+class DashboardAction implements Tabextension
+{
+ /**
+ * Applies the dashboard actions to the provided tabset
+ *
+ * @param Tabs $tabs The tabs object to extend with
+ */
+ public function apply(Tabs $tabs)
+ {
+ $tabs->addAsDropdown(
+ 'dashboard',
+ array(
+ 'icon' => 'dashboard',
+ 'label' => t('Add To Dashboard'),
+ 'url' => Url::fromPath('dashboard/new-dashlet'),
+ 'urlParams' => array(
+ 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl())
+ )
+ )
+ );
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php
new file mode 100644
index 0000000..fc7412a
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Dashboard settings
+ */
+class DashboardSettings implements Tabextension
+{
+ /**
+ * Apply this tabextension to the provided tabs
+ *
+ * @param Tabs $tabs The tabbar to modify
+ */
+ public function apply(Tabs $tabs)
+ {
+ $tabs->addAsDropdown(
+ 'dashboard_add',
+ array(
+ 'icon' => 'dashboard',
+ 'label' => t('Add Dashlet'),
+ 'url' => Url::fromPath('dashboard/new-dashlet')
+ )
+ );
+
+ $tabs->addAsDropdown(
+ 'dashboard_settings',
+ array(
+ 'icon' => 'dashboard',
+ 'label' => t('Settings'),
+ 'url' => Url::fromPath('dashboard/settings')
+ )
+ );
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/MenuAction.php b/library/Icinga/Web/Widget/Tabextension/MenuAction.php
new file mode 100644
index 0000000..d713892
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/MenuAction.php
@@ -0,0 +1,35 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension that allows to add the current URL as menu entry
+ *
+ * Displayed as a dropdown field in the tabs
+ */
+class MenuAction implements Tabextension
+{
+ /**
+ * Applies the menu actions to the provided tabset
+ *
+ * @param Tabs $tabs The tabs object to extend with
+ */
+ public function apply(Tabs $tabs)
+ {
+ $tabs->addAsDropdown(
+ 'menu-entry',
+ array(
+ 'icon' => 'menu',
+ 'label' => t('Add To Menu'),
+ 'url' => Url::fromPath('navigation/add'),
+ 'urlParams' => array(
+ 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl())
+ )
+ )
+ );
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/OutputFormat.php b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php
new file mode 100644
index 0000000..d5d83af
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php
@@ -0,0 +1,114 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Application\Platform;
+use Icinga\Application\Hook;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tab;
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension that offers different output formats for the user in the dropdown area
+ */
+class OutputFormat implements Tabextension
+{
+ /**
+ * PDF output type
+ */
+ const TYPE_PDF = 'pdf';
+
+ /**
+ * JSON output type
+ */
+ const TYPE_JSON = 'json';
+
+ /**
+ * CSV output type
+ */
+ const TYPE_CSV = 'csv';
+
+ /**
+ * An array of tabs to be added to the dropdown area
+ *
+ * @var array
+ */
+ private $tabs = array();
+
+ /**
+ * Create a new OutputFormat extender
+ *
+ * In general, it's assumed that all types are supported when an outputFormat extension
+ * is added, so this class offers to remove specific types instead of adding ones
+ *
+ * @param array $disabled An array of output types to <b>not</b> show.
+ */
+ public function __construct(array $disabled = array())
+ {
+ foreach ($this->getSupportedTypes() as $type => $tabConfig) {
+ if (!in_array($type, $disabled)) {
+ $tabConfig['url'] = Url::fromRequest();
+ $tab = new Tab($tabConfig);
+ $tab->setTargetBlank();
+ $this->tabs[] = $tab;
+ }
+ }
+ }
+
+ /**
+ * Applies the format selectio to the provided tabset
+ *
+ * @param Tabs $tabs The tabs object to extend with
+ *
+ * @see Tabextension::apply()
+ */
+ public function apply(Tabs $tabs)
+ {
+ foreach ($this->tabs as $tab) {
+ $tabs->addAsDropdown($tab->getName(), $tab);
+ }
+ }
+
+ /**
+ * Return an array containing the tab definitions for all supported types
+ *
+ * Using array_keys on this array or isset allows to check whether a
+ * requested type is supported
+ *
+ * @return array
+ */
+ public function getSupportedTypes()
+ {
+ $supportedTypes = array();
+
+ $pdfexport = Hook::has('Pdfexport');
+
+ if ($pdfexport || Platform::extensionLoaded('gd')) {
+ $supportedTypes[self::TYPE_PDF] = array(
+ 'name' => 'pdf',
+ 'label' => 'PDF',
+ 'icon' => 'file-pdf',
+ 'urlParams' => array('format' => 'pdf'),
+ );
+ }
+
+ $supportedTypes[self::TYPE_CSV] = array(
+ 'name' => 'csv',
+ 'label' => 'CSV',
+ 'icon' => 'file-excel',
+ 'urlParams' => array('format' => 'csv')
+ );
+
+ if (Platform::extensionLoaded('json')) {
+ $supportedTypes[self::TYPE_JSON] = array(
+ 'name' => 'json',
+ 'label' => 'JSON',
+ 'icon' => 'doc-text',
+ 'urlParams' => array('format' => 'json')
+ );
+ }
+
+ return $supportedTypes;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Tabextension/Tabextension.php b/library/Icinga/Web/Widget/Tabextension/Tabextension.php
new file mode 100644
index 0000000..ea49c4b
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabextension/Tabextension.php
@@ -0,0 +1,25 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget\Tabextension;
+
+use Icinga\Web\Widget\Tabs;
+
+/**
+ * Tabextension interface that allows to extend a tabbar with reusable components
+ *
+ * Tabs can be either extended by creating a `Tabextension` and calling the `apply()` method
+ * or by calling the `\Icinga\Web\Widget\Tabs` `extend()` method and providing
+ * a tab extension
+ *
+ * @see \Icinga\Web\Widget\Tabs::extend()
+ */
+interface Tabextension
+{
+ /**
+ * Apply this tabextension to the provided tabs
+ *
+ * @param Tabs $tabs The tabbar to modify
+ */
+ public function apply(Tabs $tabs);
+}
diff --git a/library/Icinga/Web/Widget/Tabs.php b/library/Icinga/Web/Widget/Tabs.php
new file mode 100644
index 0000000..9efa423
--- /dev/null
+++ b/library/Icinga/Web/Widget/Tabs.php
@@ -0,0 +1,453 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Exception;
+use Icinga\Exception\Http\HttpNotFoundException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabextension\Tabextension;
+use Icinga\Application\Icinga;
+use Countable;
+
+/**
+ * Navigation tab widget
+ */
+class Tabs extends AbstractWidget implements Countable
+{
+ /**
+ * Template used for the base tabs
+ *
+ * @var string
+ */
+ private $baseTpl = <<< 'EOT'
+<ul class="tabs primary-nav nav">
+ {TABS}
+ {DROPDOWN}
+ {REFRESH}
+ {CLOSE}
+</ul>
+EOT;
+
+ /**
+ * Template used for the tabs dropdown
+ *
+ * @var string
+ */
+ private $dropdownTpl = <<< 'EOT'
+<li class="dropdown-nav-item">
+ <a href="#" class="dropdown-toggle" title="{TITLE}" aria-label="{TITLE}">
+ <i aria-hidden="true" class="icon-down-open"></i>
+ </a>
+ <ul class="nav">
+ {TABS}
+ </ul>
+</li>
+EOT;
+
+ /**
+ * Template used for the close-button
+ *
+ * @var string
+ */
+ private $closeTpl = <<< 'EOT'
+<li class="close-container-btn">
+ <a href="#" title="{TITLE}" aria-label="{TITLE}" class="close-container-control">
+ <i aria-hidden="true" class="icon-cancel"></i>
+ </a>
+</li>
+EOT;
+
+ /**
+ * Template used for the refresh icon
+ *
+ * @var string
+ */
+ private $refreshTpl = <<< 'EOT'
+<li>
+ <a class="refresh-container-control spinner" href="{URL}" title="{TITLE}" aria-label="{LABEL}">
+ <i aria-hidden="true" class="icon-cw"></i>
+ </a>
+</li>
+EOT;
+
+ /**
+ * This is where single tabs added to this container will be stored
+ *
+ * @var array
+ */
+ private $tabs = array();
+
+ /**
+ * The name of the currently activated tab
+ *
+ * @var string
+ */
+ private $active;
+
+ /**
+ * Array of tab names which should be displayed in a dropdown
+ *
+ * @var array
+ */
+ private $dropdownTabs = array();
+
+ /**
+ * Whether only the close-button should by rendered for this tab
+ *
+ * @var bool
+ */
+ private $closeButtonOnly = false;
+
+ /**
+ * Whether the tabs should contain a close-button
+ *
+ * @var bool
+ */
+ private $closeTab = true;
+
+ /**
+ * CSS class name(s) for the &lt;ul&gt; element
+ *
+ * @var string
+ */
+ private $tab_class;
+
+ /**
+ * Set whether the current tab is closable
+ */
+ public function hideCloseButton()
+ {
+ $this->closeTab = false;
+ }
+
+ /**
+ * Activate the tab with the given name
+ *
+ * If another tab is currently active it will be deactivated
+ *
+ * @param string $name Name of the tab going to be activated
+ *
+ * @return $this
+ *
+ * @throws HttpNotFoundException When the tab w/ the given name does not exist
+ *
+ */
+ public function activate($name)
+ {
+ if (! $this->has($name)) {
+ throw new HttpNotFoundException('Can\'t activate tab %s. Tab does not exist', $name);
+ }
+
+ if ($this->active !== null) {
+ $this->tabs[$this->active]->setActive(false);
+ }
+ $this->get($name)->setActive();
+ $this->active = $name;
+
+ return $this;
+ }
+
+ /**
+ * Return the name of the active tab
+ *
+ * @return string
+ */
+ public function getActiveName()
+ {
+ return $this->active;
+ }
+
+ /**
+ * Set the CSS class name(s) for the &lt;ul&gt; element
+ *
+ * @param string $name CSS class name(s)
+ *
+ * @return $this
+ */
+ public function setClass($name)
+ {
+ $this->tab_class = $name;
+ return $this;
+ }
+
+ /**
+ * Whether the given tab name exists
+ *
+ * @param string $name Tab name
+ *
+ * @return bool
+ */
+ public function has($name)
+ {
+ return array_key_exists($name, $this->tabs);
+ }
+
+ /**
+ * Whether the given tab name exists
+ *
+ * @param string $name The tab you're interested in
+ *
+ * @return Tab
+ *
+ * @throws ProgrammingError When the given tab name doesn't exist
+ */
+ public function get($name)
+ {
+ if (!$this->has($name)) {
+ return null;
+ }
+ return $this->tabs[$name];
+ }
+
+ /**
+ * Add a new tab
+ *
+ * A unique tab name is required, the Tab itself can either be an array
+ * with tab properties or an instance of an existing Tab
+ *
+ * @param string $name The new tab name
+ * @param array|Tab $tab The tab itself of its properties
+ *
+ * @return $this
+ *
+ * @throws ProgrammingError When the tab name already exists
+ */
+ public function add($name, $tab)
+ {
+ if ($this->has($name)) {
+ throw new ProgrammingError(
+ 'Cannot add a tab named "%s" twice"',
+ $name
+ );
+ }
+ return $this->set($name, $tab);
+ }
+
+ /**
+ * Set a tab
+ *
+ * A unique tab name is required, will be replaced in case it already
+ * exists. The tab can either be an array with tab properties or an instance
+ * of an existing Tab
+ *
+ * @param string $name The new tab name
+ * @param array|Tab $tab The tab itself of its properties
+ *
+ * @return $this
+ */
+ public function set($name, $tab)
+ {
+ if ($tab instanceof Tab) {
+ $this->tabs[$name] = $tab;
+ } else {
+ $this->tabs[$name] = new Tab($tab + array('name' => $name));
+ }
+ return $this;
+ }
+
+ /**
+ * Remove a tab
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function remove($name)
+ {
+ if ($this->has($name)) {
+ unset($this->tabs[$name]);
+ if (($dropdownIndex = array_search($name, $this->dropdownTabs, true)) !== false) {
+ array_splice($this->dropdownTabs, $dropdownIndex, 1);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a tab to the dropdown on the right side of the tab-bar.
+ *
+ * @param $name
+ * @param $tab
+ */
+ public function addAsDropdown($name, $tab)
+ {
+ $this->set($name, $tab);
+ $this->dropdownTabs[] = $name;
+ $this->dropdownTabs = array_unique($this->dropdownTabs);
+ }
+
+ /**
+ * Render the dropdown area with its tabs and return the resulting HTML
+ *
+ * @return mixed|string
+ */
+ private function renderDropdownTabs()
+ {
+ if (empty($this->dropdownTabs)) {
+ return '';
+ }
+ $tabs = '';
+ foreach ($this->dropdownTabs as $tabname) {
+ $tab = $this->get($tabname);
+ if ($tab === null) {
+ continue;
+ }
+ $tabs .= $tab;
+ }
+ return str_replace(array('{TABS}', '{TITLE}'), array($tabs, t('Dropdown menu')), $this->dropdownTpl);
+ }
+
+ /**
+ * Render all tabs, except the ones in dropdown area and return the resulting HTML
+ *
+ * @return string
+ */
+ private function renderTabs()
+ {
+ $tabs = '';
+ foreach ($this->tabs as $name => $tab) {
+ // ignore tabs added to dropdown
+ if (in_array($name, $this->dropdownTabs)) {
+ continue;
+ }
+ $tabs .= $tab;
+ }
+ return $tabs;
+ }
+
+ private function renderCloseTab()
+ {
+ return str_replace('{TITLE}', t('Close container'), $this->closeTpl);
+ }
+
+ private function renderRefreshTab()
+ {
+ $url = Url::fromRequest();
+ $tab = $this->get($this->getActiveName());
+
+ if ($tab !== null) {
+ $label = $this->view()->escape(
+ $tab->getLabel()
+ );
+ }
+
+ if (! empty($label)) {
+ $caption = $label;
+ } else {
+ $caption = t('Content');
+ }
+
+ $label = sprintf(t('Refresh the %s'), $caption);
+ $title = $label;
+
+ $tpl = str_replace(
+ array(
+ '{URL}',
+ '{TITLE}',
+ '{LABEL}'
+ ),
+ array(
+ $this->view()->escape($url->getAbsoluteUrl()),
+ $title,
+ $label
+ ),
+ $this->refreshTpl
+ );
+
+ return $tpl;
+ }
+
+ /**
+ * Render to HTML
+ *
+ * @see Widget::render
+ */
+ public function render()
+ {
+ if (empty($this->tabs) || true === $this->closeButtonOnly) {
+ $tabs = '';
+ $drop = '';
+ } else {
+ $tabs = $this->renderTabs();
+ $drop = $this->renderDropdownTabs();
+ }
+ $close = $this->closeTab ? $this->renderCloseTab() : '';
+ $refresh = $this->renderRefreshTab();
+
+ return str_replace(
+ array(
+ '{TABS}',
+ '{DROPDOWN}',
+ '{REFRESH}',
+ '{CLOSE}'
+ ),
+ array(
+ $tabs,
+ $drop,
+ $refresh,
+ $close
+ ),
+ $this->baseTpl
+ );
+ }
+
+ public function __toString()
+ {
+ try {
+ $html = $this->render();
+ } catch (Exception $e) {
+ return htmlspecialchars($e->getMessage());
+ }
+ return $html;
+ }
+
+ /**
+ * Return the number of tabs
+ *
+ * @return int
+ *
+ * @see Countable
+ */
+ public function count(): int
+ {
+ return count($this->tabs);
+ }
+
+ /**
+ * Return all tabs contained in this tab panel
+ *
+ * @return array
+ */
+ public function getTabs()
+ {
+ return $this->tabs;
+ }
+
+ /**
+ * Whether to hide all elements except of the close button
+ *
+ * @param bool $value
+ * @return Tabs fluent interface
+ */
+ public function showOnlyCloseButton($value = true)
+ {
+ $this->closeButtonOnly = $value;
+ return $this;
+ }
+
+ /**
+ * Apply a Tabextension on this tabs object
+ *
+ * @param Tabextension $tabextension
+ *
+ * @return $this
+ */
+ public function extend(Tabextension $tabextension)
+ {
+ $tabextension->apply($this);
+ return $this;
+ }
+}
diff --git a/library/Icinga/Web/Widget/Widget.php b/library/Icinga/Web/Widget/Widget.php
new file mode 100644
index 0000000..879858a
--- /dev/null
+++ b/library/Icinga/Web/Widget/Widget.php
@@ -0,0 +1,24 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Web\Widget;
+
+use Icinga\Web\View;
+use Zend_View_Abstract;
+
+/**
+ * Abstract class for reusable view elements that can be
+ * rendered to a view
+ *
+ */
+interface Widget
+{
+ /**
+ * Renders this widget via the given view and returns the
+ * HTML as a string
+ *
+ * @param \Zend_View_Abstract $view
+ * @return string
+ */
+ // public function render(Zend_View_Abstract $view);
+}