diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
commit | 3e02d5aff85babc3ffbfcf52313f2108e313aa23 (patch) | |
tree | b01f3923360c20a6a504aff42d45670c58af3ec5 /library/Icinga/Web/Widget | |
parent | Initial commit. (diff) | |
download | icingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.tar.xz icingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.zip |
Adding upstream version 2.12.1.upstream/2.12.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Icinga/Web/Widget')
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 <li> 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 <ul> 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 <ul> 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); +} |