summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Common
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:36:40 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:36:40 +0000
commita0901c4b7f2db488cb4fb3be2dd921a0308f4659 (patch)
treefafb393cf330a60df129ff10d0059eb7b14052a7 /library/Icingadb/Common
parentInitial commit. (diff)
downloadicingadb-web-a0901c4b7f2db488cb4fb3be2dd921a0308f4659.tar.xz
icingadb-web-a0901c4b7f2db488cb4fb3be2dd921a0308f4659.zip
Adding upstream version 1.0.2.upstream/1.0.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Icingadb/Common')
-rw-r--r--library/Icingadb/Common/Auth.php358
-rw-r--r--library/Icingadb/Common/BaseFilter.php50
-rw-r--r--library/Icingadb/Common/BaseItemList.php99
-rw-r--r--library/Icingadb/Common/BaseListItem.php165
-rw-r--r--library/Icingadb/Common/BaseOrderedItemList.php34
-rw-r--r--library/Icingadb/Common/BaseOrderedListItem.php41
-rw-r--r--library/Icingadb/Common/BaseStatusBar.php45
-rw-r--r--library/Icingadb/Common/BaseTableRowItem.php102
-rw-r--r--library/Icingadb/Common/CaptionDisabled.php30
-rw-r--r--library/Icingadb/Common/CommandActions.php254
-rw-r--r--library/Icingadb/Common/Database.php102
-rw-r--r--library/Icingadb/Common/DetailActions.php140
-rw-r--r--library/Icingadb/Common/HostLink.php27
-rw-r--r--library/Icingadb/Common/HostLinks.php76
-rw-r--r--library/Icingadb/Common/HostStates.php118
-rw-r--r--library/Icingadb/Common/IcingaRedis.php236
-rw-r--r--library/Icingadb/Common/Icons.php30
-rw-r--r--library/Icingadb/Common/Links.php143
-rw-r--r--library/Icingadb/Common/ListItemCommonLayout.php26
-rw-r--r--library/Icingadb/Common/ListItemDetailedLayout.php23
-rw-r--r--library/Icingadb/Common/ListItemMinimalLayout.php26
-rw-r--r--library/Icingadb/Common/LoadMore.php108
-rw-r--r--library/Icingadb/Common/Macros.php118
-rw-r--r--library/Icingadb/Common/NoSubjectLink.php35
-rw-r--r--library/Icingadb/Common/ObjectInspectionDetail.php330
-rw-r--r--library/Icingadb/Common/ObjectLinkDisabled.php35
-rw-r--r--library/Icingadb/Common/SearchControls.php69
-rw-r--r--library/Icingadb/Common/ServiceLink.php40
-rw-r--r--library/Icingadb/Common/ServiceLinks.php108
-rw-r--r--library/Icingadb/Common/ServiceStates.php129
-rw-r--r--library/Icingadb/Common/StateBadges.php185
-rw-r--r--library/Icingadb/Common/TicketLinks.php56
-rw-r--r--library/Icingadb/Common/ViewMode.php35
33 files changed, 3373 insertions, 0 deletions
diff --git a/library/Icingadb/Common/Auth.php b/library/Icingadb/Common/Auth.php
new file mode 100644
index 0000000..c415d62
--- /dev/null
+++ b/library/Icingadb/Common/Auth.php
@@ -0,0 +1,358 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Authentication\Auth as IcingaAuth;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Icingadb\Authentication\ObjectAuthorization;
+use Icinga\Util\StringHelper;
+use ipl\Orm\Compat\FilterProcessor;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\UnionQuery;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+
+trait Auth
+{
+ public function getAuth(): IcingaAuth
+ {
+ return IcingaAuth::getInstance();
+ }
+
+ /**
+ * Check whether access to the given route is permitted
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function isPermittedRoute(string $name): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return true;
+ }
+
+ // The empty array is for PHP pre 7.4, older versions require at least a single param for array_merge
+ $routeDenylist = array_flip(array_merge([], ...array_map(function ($restriction) {
+ return StringHelper::trimSplit($restriction);
+ }, $this->getAuth()->getRestrictions('icingadb/denylist/routes'))));
+
+ return ! array_key_exists($name, $routeDenylist);
+ }
+
+ /**
+ * Check whether the permission is granted on the object
+ *
+ * @param string $permission
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function isGrantedOn(string $permission, Model $object): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return $this->getAuth()->hasPermission($permission);
+ }
+
+ return ObjectAuthorization::grantsOn($permission, $object);
+ }
+
+ /**
+ * Check whether the permission is granted on objects matching the type and filter
+ *
+ * The check will be performed on every object matching the filter. Though the result
+ * only allows to determine whether the permission is granted on **any** or *none*
+ * of the objects in question. Any subsequent call to {@see Auth::isGrantedOn} will
+ * make use of the underlying results the check has determined in order to avoid
+ * unnecessary queries.
+ *
+ * @param string $permission
+ * @param string $type
+ * @param Filter\Rule $filter
+ * @param bool $cache Pass `false` to not perform the check on every object
+ *
+ * @return bool
+ */
+ public function isGrantedOnType(string $permission, string $type, Filter\Rule $filter, bool $cache = true): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return $this->getAuth()->hasPermission($permission);
+ }
+
+ return ObjectAuthorization::grantsOnType($permission, $type, $filter, $cache);
+ }
+
+ /**
+ * Check whether the filter matches the given object
+ *
+ * @param string $queryString
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function isMatchedOn(string $queryString, Model $object): bool
+ {
+ return ObjectAuthorization::matchesOn($queryString, $object);
+ }
+
+ /**
+ * Apply Icinga DB Web's restrictions depending on what is queried
+ *
+ * This will apply `icingadb/filter/objects` in any case. `icingadb/filter/services` is only
+ * applied to queries fetching services and `icingadb/filter/hosts` is applied to queries
+ * fetching either hosts or services. It also applies custom variable restrictions and
+ * obfuscations. (`icingadb/denylist/variables` and `icingadb/protect/variables`)
+ *
+ * @param Query $query
+ *
+ * @return void
+ */
+ public function applyRestrictions(Query $query)
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return;
+ }
+
+ if ($query instanceof UnionQuery) {
+ $queries = $query->getUnions();
+ } else {
+ $queries = [$query];
+ }
+
+ $orgQuery = $query;
+ foreach ($queries as $query) {
+ $relations = [$query->getModel()->getTableName()];
+ foreach ($query->getWith() as $relationPath => $relation) {
+ $relations[$relationPath] = $relation->getTarget()->getTableName();
+ }
+
+ $customVarRelationName = array_search('customvar_flat', $relations, true);
+ $applyServiceRestriction = in_array('service', $relations, true);
+ $applyHostRestriction = in_array('host', $relations, true)
+ // Hosts and services have a special relation as a service can't exist without its host.
+ // Hence why the hosts restriction is also applied if only services are queried.
+ || $applyServiceRestriction;
+
+ $resolver = $query->getResolver();
+
+ $queryFilter = Filter::any();
+ $obfuscationRules = Filter::any();
+ foreach ($this->getAuth()->getUser()->getRoles() as $role) {
+ $roleFilter = Filter::all();
+
+ if ($customVarRelationName !== false) {
+ if (($restriction = $role->getRestrictions('icingadb/denylist/variables'))) {
+ $roleFilter->add($this->parseDenylist(
+ $restriction,
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname'
+ ));
+ }
+
+ if (($restriction = $role->getRestrictions('icingadb/protect/variables'))) {
+ $obfuscationRules->add($this->parseDenylist(
+ $restriction,
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname'
+ ));
+ }
+ }
+
+ if ($customVarRelationName === false || count($relations) > 1) {
+ if (($restriction = $role->getRestrictions('icingadb/filter/objects'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/objects'));
+ }
+
+ if ($applyHostRestriction && ($restriction = $role->getRestrictions('icingadb/filter/hosts'))) {
+ $hostFilter = $this->parseRestriction($restriction, 'icingadb/filter/hosts');
+ if ($orgQuery instanceof UnionQuery) {
+ $this->forceQueryOptimization($hostFilter, 'hostgroup.name');
+ }
+
+ $roleFilter->add($hostFilter);
+ }
+
+ if (
+ $applyServiceRestriction
+ && ($restriction = $role->getRestrictions('icingadb/filter/services'))
+ ) {
+ $serviceFilter = $this->parseRestriction($restriction, 'icingadb/filter/services');
+ if ($orgQuery instanceof UnionQuery) {
+ $this->forceQueryOptimization($serviceFilter, 'servicegroup.name');
+ }
+
+ $roleFilter->add(Filter::any(Filter::unlike('service.id', '*'), $serviceFilter));
+ }
+ }
+
+ if (! $roleFilter->isEmpty()) {
+ $queryFilter->add($roleFilter);
+ }
+ }
+
+ if (! $obfuscationRules->isEmpty()) {
+ $flatvaluePath = $customVarRelationName
+ ? $resolver->qualifyColumn('flatvalue', $customVarRelationName)
+ : 'flatvalue';
+
+ $columns = $query->getColumns();
+ if (empty($columns)) {
+ $columns = [
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname',
+ $flatvaluePath
+ ];
+ }
+
+ $flatvalue = null;
+ if (isset($columns[$flatvaluePath])) {
+ $flatvalue = $columns[$flatvaluePath];
+ } else {
+ $flatvaluePathAt = array_search($flatvaluePath, $columns, true);
+ if ($flatvaluePathAt !== false) {
+ $flatvalue = $columns[$flatvaluePathAt];
+ if (is_int($flatvaluePathAt)) {
+ unset($columns[$flatvaluePathAt]);
+ } else {
+ $flatvaluePath = $flatvaluePathAt;
+ }
+ }
+ }
+
+ if ($flatvalue !== null) {
+ // TODO: The four lines below are needed because there is still no way to postpone filter column
+ // qualification. (i.e. Just like the expression, filter rules need to be handled the same
+ // so that their columns are qualified lazily when assembling the query)
+ $queryClone = clone $query;
+ $queryClone->getSelectBase()->resetWhere();
+ FilterProcessor::apply($obfuscationRules, $queryClone);
+ $where = $queryClone->getSelectBase()->getWhere();
+
+ $values = [];
+ $rendered = $query->getDb()->getQueryBuilder()->buildCondition($where, $values);
+ $columns[$flatvaluePath] = new Expression(
+ "CASE WHEN (" . $rendered . ") THEN (%s) ELSE '***' END",
+ [$flatvalue],
+ ...$values
+ );
+
+ $query->columns($columns);
+ }
+ }
+
+ $query->filter($queryFilter);
+ }
+ }
+
+ /**
+ * Parse the given restriction
+ *
+ * @param string $queryString
+ * @param string $restriction The name of the restriction
+ *
+ * @return Filter\Rule
+ */
+ protected function parseRestriction(string $queryString, string $restriction): Filter\Rule
+ {
+ $allowedColumns = [
+ 'host.name',
+ 'hostgroup.name',
+ 'host.user.name',
+ 'host.usergroup.name',
+ 'service.name',
+ 'servicegroup.name',
+ 'service.user.name',
+ 'service.usergroup.name',
+ '(host|service).vars.<customvar-name>' => function ($c) {
+ return preg_match('/^(?:host|service)\.vars\./i', $c);
+ }
+ ];
+
+ return QueryString::fromString($queryString)
+ ->on(
+ QueryString::ON_CONDITION,
+ function (Filter\Condition $condition) use (
+ $restriction,
+ $queryString,
+ $allowedColumns
+ ) {
+ foreach ($allowedColumns as $column) {
+ if (is_callable($column)) {
+ if ($column($condition->getColumn())) {
+ return;
+ }
+ } elseif ($column === $condition->getColumn()) {
+ return;
+ }
+ }
+
+ throw new ConfigurationError(
+ t(
+ 'Cannot apply restriction %s using the filter %s.'
+ . ' You can only use the following columns: %s'
+ ),
+ $restriction,
+ $queryString,
+ join(
+ ', ',
+ array_map(
+ function ($k, $v) {
+ return is_string($k) ? $k : $v;
+ },
+ array_keys($allowedColumns),
+ $allowedColumns
+ )
+ )
+ );
+ }
+ )->parse();
+ }
+
+ /**
+ * Parse the given denylist
+ *
+ * @param string $denylist Comma separated list of column names
+ * @param string $column The column which should not equal any of the denylisted names
+ *
+ * @return Filter\None
+ */
+ protected function parseDenylist(string $denylist, string $column): Filter\None
+ {
+ $filter = Filter::none();
+ foreach (explode(',', $denylist) as $value) {
+ $filter->add(Filter::like($column, trim($value)));
+ }
+
+ return $filter;
+ }
+
+ /**
+ * Force query optimization on the given service/host filter rule
+ *
+ * Applies forceOptimization, when the given filter rule contains the given filter column
+ *
+ * @param Filter\Rule $filterRule
+ * @param string $filterColumn
+ *
+ * @return void
+ */
+ protected function forceQueryOptimization(Filter\Rule $filterRule, string $filterColumn)
+ {
+ // TODO: This is really a very poor solution is therefore only a quick fix.
+ // We need to somehow manage to make this more enjoyable and creative!
+ if ($filterRule instanceof Filter\Chain) {
+ foreach ($filterRule as $rule) {
+ $this->forceQueryOptimization($rule, $filterColumn);
+ }
+ } elseif ($filterRule->getColumn() === $filterColumn) {
+ $filterRule->metaData()->set('forceOptimization', true);
+ }
+ }
+}
diff --git a/library/Icingadb/Common/BaseFilter.php b/library/Icingadb/Common/BaseFilter.php
new file mode 100644
index 0000000..10ddafe
--- /dev/null
+++ b/library/Icingadb/Common/BaseFilter.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Stdlib\Filter\Rule;
+
+/**
+ * @deprecated Use {@see \ipl\Stdlib\BaseFilter} instead. This will be removed with version 1.1
+ */
+trait BaseFilter
+{
+ /** @var Rule Base filter */
+ private $baseFilter;
+
+ /**
+ * Get whether a base filter has been set
+ *
+ * @return bool
+ */
+ public function hasBaseFilter(): bool
+ {
+ return $this->baseFilter !== null;
+ }
+
+ /**
+ * Get the base filter
+ *
+ * @return ?Rule
+ */
+ public function getBaseFilter()
+ {
+ return $this->baseFilter;
+ }
+
+ /**
+ * Set the base filter
+ *
+ * @param Rule $baseFilter
+ *
+ * @return $this
+ */
+ public function setBaseFilter(Rule $baseFilter = null): self
+ {
+ $this->baseFilter = $baseFilter;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/BaseItemList.php b/library/Icingadb/Common/BaseItemList.php
new file mode 100644
index 0000000..7eacb28
--- /dev/null
+++ b/library/Icingadb/Common/BaseItemList.php
@@ -0,0 +1,99 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * Base class for item lists
+ */
+abstract class BaseItemList extends BaseHtmlElement
+{
+ use BaseFilter;
+ use DetailActions;
+
+ protected $baseAttributes = [
+ 'class' => 'item-list',
+ 'data-base-target' => '_next',
+ 'data-pdfexport-page-breaks-at' => '.list-item'
+ ];
+
+ /** @var iterable */
+ protected $data;
+
+ /** @var bool Whether the list contains at least one item with an icon_image */
+ protected $hasIconImages = false;
+
+ protected $tag = 'ul';
+
+ /**
+ * Create a new item list
+ *
+ * @param iterable $data Data source of the list
+ */
+ public function __construct($data)
+ {
+ if (! is_iterable($data)) {
+ throw new InvalidArgumentException('Data must be an array or an instance of Traversable');
+ }
+
+ $this->data = $data;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->initializeDetailActions();
+ $this->init();
+ }
+
+ abstract protected function getItemClass(): string;
+
+ /**
+ * Get whether the list contains at least one item with an icon_image
+ *
+ * @return bool
+ */
+ public function hasIconImages(): bool
+ {
+ return $this->hasIconImages;
+ }
+
+ /**
+ * Set whether the list contains at least one item with an icon_image
+ *
+ * @param bool $hasIconImages
+ */
+ public function setHasIconImages(bool $hasIconImages)
+ {
+ $this->hasIconImages = $hasIconImages;
+ }
+
+ /**
+ * Initialize the item list
+ *
+ * If you want to adjust the item list after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ protected function assemble()
+ {
+ $itemClass = $this->getItemClass();
+
+ foreach ($this->data as $data) {
+ /** @var BaseListItem|BaseTableRowItem $item */
+ $item = new $itemClass($data, $this);
+
+ $this->add($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->add(new EmptyState(t('No items found.')));
+ }
+ }
+}
diff --git a/library/Icingadb/Common/BaseListItem.php b/library/Icingadb/Common/BaseListItem.php
new file mode 100644
index 0000000..c552bb6
--- /dev/null
+++ b/library/Icingadb/Common/BaseListItem.php
@@ -0,0 +1,165 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Stdlib\Filter\Rule;
+use ipl\Web\Filter\QueryString;
+
+/**
+ * Base class for list items
+ */
+abstract class BaseListItem extends BaseHtmlElement
+{
+ protected $baseAttributes = ['class' => 'list-item'];
+
+ /** @var object The associated list item */
+ protected $item;
+
+ /** @var BaseItemList The list where the item is part of */
+ protected $list;
+
+ protected $tag = 'li';
+
+ /**
+ * Create a new list item
+ *
+ * @param object $item
+ * @param BaseItemList $list
+ */
+ public function __construct($item, BaseItemList $list)
+ {
+ $this->item = $item;
+ $this->list = $list;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ abstract protected function assembleHeader(BaseHtmlElement $header);
+
+ abstract protected function assembleMain(BaseHtmlElement $main);
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption)
+ {
+ }
+
+ protected function assembleIconImage(BaseHtmlElement $iconImage)
+ {
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title)
+ {
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ }
+
+ protected function createCaption(): BaseHtmlElement
+ {
+ $caption = Html::tag('section', ['class' => 'caption']);
+
+ $this->assembleCaption($caption);
+
+ return $caption;
+ }
+
+ protected function createHeader(): BaseHtmlElement
+ {
+ $header = Html::tag('header');
+
+ $this->assembleHeader($header);
+
+ return $header;
+ }
+
+ protected function createMain(): BaseHtmlElement
+ {
+ $main = Html::tag('div', ['class' => 'main']);
+
+ $this->assembleMain($main);
+
+ return $main;
+ }
+
+ protected function createFooter(): BaseHtmlElement
+ {
+ $footer = new HtmlElement('footer');
+
+ $this->assembleFooter($footer);
+
+ return $footer;
+ }
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createIconImage()
+ {
+ if (! $this->list->hasIconImages()) {
+ return null;
+ }
+
+ $iconImage = HtmlElement::create('div', [
+ 'class' => 'icon-image',
+ ]);
+
+ $this->assembleIconImage($iconImage);
+
+ return $iconImage;
+ }
+
+ protected function createTimestamp()
+ {
+ }
+
+ protected function createTitle(): BaseHtmlElement
+ {
+ $title = HTML::tag('div', ['class' => 'title']);
+
+ $this->assembleTitle($title);
+
+ return $title;
+ }
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createVisual()
+ {
+ $visual = Html::tag('div', ['class' => 'visual']);
+
+ $this->assembleVisual($visual);
+
+ return $visual;
+ }
+
+ /**
+ * Initialize the list item
+ *
+ * If you want to adjust the list item after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createVisual(),
+ $this->createIconImage(),
+ $this->createMain()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/BaseOrderedItemList.php b/library/Icingadb/Common/BaseOrderedItemList.php
new file mode 100644
index 0000000..23ae7e9
--- /dev/null
+++ b/library/Icingadb/Common/BaseOrderedItemList.php
@@ -0,0 +1,34 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+
+/**
+ * @method BaseOrderedListItem getItemClass()
+ */
+abstract class BaseOrderedItemList extends BaseItemList
+{
+ protected $tag = 'ol';
+
+ protected function assemble()
+ {
+ $itemClass = $this->getItemClass();
+
+ $i = 0;
+ foreach ($this->data as $data) {
+ $item = new $itemClass($data, $this);
+ $item->setOrder($i++);
+
+ $this->add($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->add(new EmptyState(t('No items found.')));
+ }
+ }
+}
diff --git a/library/Icingadb/Common/BaseOrderedListItem.php b/library/Icingadb/Common/BaseOrderedListItem.php
new file mode 100644
index 0000000..bf0f2b2
--- /dev/null
+++ b/library/Icingadb/Common/BaseOrderedListItem.php
@@ -0,0 +1,41 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+abstract class BaseOrderedListItem extends BaseListItem
+{
+ /** @var int This element's position */
+ protected $order;
+
+ /**
+ * Set this element's position
+ *
+ * @param int $order
+ *
+ * @return $this
+ */
+ public function setOrder(int $order): self
+ {
+ $this->order = $order;
+
+ return $this;
+ }
+
+ /**
+ * Get this element's position
+ *
+ * @return int
+ */
+ public function getOrder()
+ {
+ if ($this->order === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->order;
+ }
+}
diff --git a/library/Icingadb/Common/BaseStatusBar.php b/library/Icingadb/Common/BaseStatusBar.php
new file mode 100644
index 0000000..7339eb2
--- /dev/null
+++ b/library/Icingadb/Common/BaseStatusBar.php
@@ -0,0 +1,45 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+abstract class BaseStatusBar extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $summary;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'status-bar'];
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ abstract protected function assembleTotal(BaseHtmlElement $total);
+
+ abstract protected function createStateBadges(): BaseHtmlElement;
+
+ protected function createCount(): BaseHtmlElement
+ {
+ $total = Html::tag('span', ['class' => 'item-count']);
+
+ $this->assembleTotal($total);
+
+ return $total;
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createCount(),
+ $this->createStateBadges()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/BaseTableRowItem.php b/library/Icingadb/Common/BaseTableRowItem.php
new file mode 100644
index 0000000..d3e0036
--- /dev/null
+++ b/library/Icingadb/Common/BaseTableRowItem.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+
+abstract class BaseTableRowItem extends BaseHtmlElement
+{
+ protected $baseAttributes = ['class' => 'list-item'];
+
+ /** @var object The associated list item */
+ protected $item;
+
+ /** @var BaseItemList The list where the item is part of */
+ protected $list;
+
+ protected $tag = 'li';
+
+ /**
+ * Create a new table row item
+ *
+ * @param object $item
+ * @param BaseItemList $list
+ */
+ public function __construct($item, BaseItemList $list)
+ {
+ $this->item = $item;
+ $this->list = $list;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ abstract protected function assembleColumns(HtmlDocument $columns);
+
+ abstract protected function assembleTitle(BaseHtmlElement $title);
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ }
+
+ protected function createColumn($content = null): BaseHtmlElement
+ {
+ return Html::tag('div', ['class' => 'col'], $content);
+ }
+
+ protected function createColumns(): HtmlDocument
+ {
+ $columns = new HtmlDocument();
+
+ $this->assembleColumns($columns);
+
+ return $columns;
+ }
+
+ protected function createTitle(): BaseHtmlElement
+ {
+ $title = $this->createColumn()->addAttributes(['class' => 'title']);
+
+ $this->assembleTitle($title);
+
+ return $title;
+ }
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createVisual()
+ {
+ $visual = new HtmlElement('div', Attributes::create(['class' => 'visual']));
+
+ $this->assembleVisual($visual);
+
+ return $visual->isEmpty() ? null : $visual;
+ }
+
+ /**
+ * Initialize the list item
+ *
+ * If you want to adjust the list item after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createVisual(),
+ $this->createTitle(),
+ $this->createColumns()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/CaptionDisabled.php b/library/Icingadb/Common/CaptionDisabled.php
new file mode 100644
index 0000000..26344c5
--- /dev/null
+++ b/library/Icingadb/Common/CaptionDisabled.php
@@ -0,0 +1,30 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait CaptionDisabled
+{
+ protected $captionDisabled = false;
+
+ /**
+ * @return bool
+ */
+ public function isCaptionDisabled(): bool
+ {
+ return $this->captionDisabled;
+ }
+
+ /**
+ * @param bool $captionDisabled
+ *
+ * @return $this
+ */
+ public function setCaptionDisabled(bool $captionDisabled = true): self
+ {
+ $this->captionDisabled = $captionDisabled;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/CommandActions.php b/library/Icingadb/Common/CommandActions.php
new file mode 100644
index 0000000..3283964
--- /dev/null
+++ b/library/Icingadb/Common/CommandActions.php
@@ -0,0 +1,254 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\AcknowledgeProblemForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\AddCommentForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ProcessCheckResultForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleCheckForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleHostDowntimeForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleServiceDowntimeForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\SendCustomNotificationForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Security\SecurityException;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Web\Url;
+
+/**
+ * Trait CommandActions
+ */
+trait CommandActions
+{
+ /** @var Query $commandTargets */
+ protected $commandTargets;
+
+ /** @var Model $commandTargetModel */
+ protected $commandTargetModel;
+
+ /**
+ * Get url to view command targets, used as redirection target
+ *
+ * @return Url
+ */
+ abstract protected function getCommandTargetsUrl(): Url;
+
+ /**
+ * Get status of toggleable features
+ *
+ * @return object
+ */
+ protected function getFeatureStatus()
+ {
+ }
+
+ /**
+ * Fetch command targets
+ *
+ * @return Query|Model[]
+ */
+ abstract protected function fetchCommandTargets();
+
+ /**
+ * Get command targets
+ *
+ * @return Query|Model[]
+ */
+ protected function getCommandTargets()
+ {
+ if (! isset($this->commandTargets)) {
+ $this->commandTargets = $this->fetchCommandTargets();
+ }
+
+ return $this->commandTargets;
+ }
+
+ /**
+ * Get the model of the command targets
+ *
+ * @return Model
+ */
+ protected function getCommandTargetModel(): Model
+ {
+ if (! isset($this->commandTargetModel)) {
+ $commandTargets = $this->getCommandTargets();
+ if (is_array($commandTargets) && !empty($commandTargets)) {
+ $this->commandTargetModel = $commandTargets[0];
+ } else {
+ $this->commandTargetModel = $commandTargets->getModel();
+ }
+ }
+
+ return $this->commandTargetModel;
+ }
+
+ /**
+ * Check whether the permission is granted on any of the command targets
+ *
+ * @param string $permission
+ *
+ * @return bool
+ */
+ protected function isGrantedOnCommandTargets(string $permission): bool
+ {
+ $commandTargets = $this->getCommandTargets();
+ if (is_array($commandTargets)) {
+ foreach ($commandTargets as $commandTarget) {
+ if ($this->isGrantedOn($permission, $commandTarget)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return $this->isGrantedOnType(
+ $permission,
+ $this->getCommandTargetModel()->getTableName(),
+ $commandTargets->getFilter()
+ );
+ }
+
+ /**
+ * Assert that the permission is granted on any of the command targets
+ *
+ * @param string $permission
+ *
+ * @throws SecurityException
+ */
+ protected function assertIsGrantedOnCommandTargets(string $permission)
+ {
+ if (! $this->isGrantedOnCommandTargets($permission)) {
+ throw new SecurityException('No permission for %s', $permission);
+ }
+ }
+
+ /**
+ * Handle and register the given command form
+ *
+ * @param string|CommandForm $form
+ *
+ * @return void
+ */
+ protected function handleCommandForm($form)
+ {
+ if (is_string($form)) {
+ /** @var \Icinga\Module\Icingadb\Forms\Command\CommandForm $form */
+ $form = new $form();
+ }
+
+ $actionUrl = $this->getRequest()->getUrl();
+ if ($this->view->compact) {
+ $actionUrl = clone $actionUrl;
+ // TODO: This solves https://github.com/Icinga/icingadb-web/issues/124 but I'd like to omit this
+ // entirely. I think it should be solved like https://github.com/Icinga/icingaweb2/pull/4300 so
+ // that a request's url object still has params like showCompact and _dev
+ $actionUrl->getParams()->add('showCompact', true);
+ }
+
+ $form->setAction($actionUrl->getAbsoluteUrl());
+ $form->setObjects($this->getCommandTargets());
+ $form->on($form::ON_SUCCESS, function () {
+ // This forces the column to reload nearly instantly after the redirect
+ // and ensures the effect of the command is visible to the user asap
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow($this->getCommandTargetsUrl());
+ });
+
+ $form->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+ }
+
+ public function acknowledgeAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/acknowledge-problem');
+ $this->setTitle(t('Acknowledge Problem'));
+ $this->handleCommandForm(AcknowledgeProblemForm::class);
+ }
+
+ public function addCommentAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/comment/add');
+ $this->setTitle(t('Add Comment'));
+ $this->handleCommandForm(AddCommentForm::class);
+ }
+
+ public function checkNowAction()
+ {
+ if (! $this->isGrantedOnCommandTargets('icingadb/command/schedule-check/active-only')) {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/schedule-check');
+ }
+
+ $this->handleCommandForm(CheckNowForm::class);
+ }
+
+ public function processCheckresultAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/process-check-result');
+ $this->setTitle(t('Submit Passive Check Result'));
+ $this->handleCommandForm(ProcessCheckResultForm::class);
+ }
+
+ public function removeAcknowledgementAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/remove-acknowledgement');
+ $this->handleCommandForm(RemoveAcknowledgementForm::class);
+ }
+
+ public function scheduleCheckAction()
+ {
+ if (! $this->isGrantedOnCommandTargets('icingadb/command/schedule-check/active-only')) {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/schedule-check');
+ }
+
+ $this->setTitle(t('Reschedule Check'));
+ $this->handleCommandForm(ScheduleCheckForm::class);
+ }
+
+ public function scheduleDowntimeAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/downtime/schedule');
+
+ switch ($this->getCommandTargetModel()->getTableName()) {
+ case 'host':
+ $this->setTitle(t('Schedule Host Downtime'));
+ $this->handleCommandForm(ScheduleHostDowntimeForm::class);
+ break;
+ case 'service':
+ $this->setTitle(t('Schedule Service Downtime'));
+ $this->handleCommandForm(ScheduleServiceDowntimeForm::class);
+ break;
+ }
+ }
+
+ public function sendCustomNotificationAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/send-custom-notification');
+ $this->setTitle(t('Send Custom Notification'));
+ $this->handleCommandForm(SendCustomNotificationForm::class);
+ }
+
+ public function toggleFeaturesAction()
+ {
+ $commandObjects = $this->getCommandTargets();
+ if (count($commandObjects) > 1) {
+ $this->isGrantedOnCommandTargets('i/am-only-used/to-establish/the-object-auth-cache');
+ $form = new ToggleObjectFeaturesForm($this->getFeatureStatus());
+ } else {
+ foreach ($commandObjects as $object) {
+ // There's only a single result, a foreach is the most compatible way to retrieve the object
+ $form = new ToggleObjectFeaturesForm($object);
+ }
+ }
+
+ $this->handleCommandForm($form);
+ }
+}
diff --git a/library/Icingadb/Common/Database.php b/library/Icingadb/Common/Database.php
new file mode 100644
index 0000000..8fa87cc
--- /dev/null
+++ b/library/Icingadb/Common/Database.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Config as AppConfig;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Config as SqlConfig;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\QueryBuilder;
+use ipl\Sql\Select;
+use PDO;
+
+trait Database
+{
+ /** @var Connection Connection to the Icinga database */
+ private $db;
+
+ /**
+ * Get the connection to the Icinga database
+ *
+ * @return Connection
+ *
+ * @throws ConfigurationError If the related resource configuration does not exist
+ */
+ public function getDb(): Connection
+ {
+ if ($this->db === null) {
+ $config = new SqlConfig(ResourceFactory::getResourceConfig(
+ AppConfig::module('icingadb')->get('icingadb', 'resource')
+ ));
+
+ $config->options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ];
+ if ($config->db === 'mysql') {
+ $config->options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION SQL_MODE='STRICT_TRANS_TABLES"
+ . ",NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'";
+ }
+
+ $this->db = new Connection($config);
+
+ $adapter = $this->db->getAdapter();
+ if ($adapter instanceof Pgsql) {
+ $quoted = $adapter->quoteIdentifier('user');
+ $this->db->getQueryBuilder()
+ ->on(QueryBuilder::ON_SELECT_ASSEMBLED, function (&$sql) use ($quoted) {
+ // user is a reserved key word in PostgreSQL, so we need to quote it.
+ // TODO(lippserd): This is pretty hacky,
+ // reconsider how to properly implement identifier quoting.
+ $sql = str_replace(' user ', sprintf(' %s ', $quoted), $sql);
+ $sql = str_replace(' user.', sprintf(' %s.', $quoted), $sql);
+ $sql = str_replace('(user.', sprintf('(%s.', $quoted), $sql);
+ })
+ ->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) {
+ // For SELECT DISTINCT, all ORDER BY columns must appear in SELECT list.
+ if (! $select->getDistinct() || ! $select->hasOrderBy()) {
+ return;
+ }
+
+ $candidates = [];
+ foreach ($select->getOrderBy() as list($columnOrAlias, $_)) {
+ if ($columnOrAlias instanceof Expression) {
+ // Expressions can be and include anything,
+ // also columns that aren't already part of the SELECT list,
+ // so we're not trying to guess anything here.
+ // Such expressions must be in the SELECT list if necessary and
+ // referenced manually with an alias in ORDER BY.
+ continue;
+ }
+
+ $candidates[$columnOrAlias] = true;
+ }
+
+ foreach ($select->getColumns() as $alias => $column) {
+ if (is_int($alias)) {
+ if ($column instanceof Expression) {
+ // This is the complement to the above consideration.
+ // If it is an unaliased expression, ignore it.
+ continue;
+ }
+ } else {
+ unset($candidates[$alias]);
+ }
+
+ if (! $column instanceof Expression) {
+ unset($candidates[$column]);
+ }
+ }
+
+ if (! empty($candidates)) {
+ $select->columns(array_keys($candidates));
+ }
+ });
+ }
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Icingadb/Common/DetailActions.php b/library/Icingadb/Common/DetailActions.php
new file mode 100644
index 0000000..3b8e0e6
--- /dev/null
+++ b/library/Icingadb/Common/DetailActions.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+trait DetailActions
+{
+ /** @var bool */
+ protected $detailActionsDisabled = false;
+
+ /**
+ * Set whether this list should be an action-list
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setDetailActionsDisabled(bool $state = true): self
+ {
+ $this->detailActionsDisabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether this list should be an action-list
+ *
+ * @return bool
+ */
+ public function getDetailActionsDisabled(): bool
+ {
+ return $this->detailActionsDisabled;
+ }
+
+ /**
+ * Prepare this list as action-list
+ *
+ * @return $this
+ */
+ public function initializeDetailActions(): self
+ {
+ $this->getAttributes()
+ ->registerAttributeCallback('class', function () {
+ return $this->getDetailActionsDisabled() ? null : 'action-list';
+ })
+ ->registerAttributeCallback('data-icinga-multiselect-count-label', function () {
+ return $this->getDetailActionsDisabled() ? null : t('%d Item(s) selected');
+ });
+
+ return $this;
+ }
+
+ /**
+ * Set the url to use for multiple selected list items
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ protected function setMultiselectUrl(Url $url): self
+ {
+ /** @var BaseHtmlElement $this */
+ $this->getAttributes()
+ ->registerAttributeCallback('data-icinga-multiselect-url', function () use ($url) {
+ return $this->getDetailActionsDisabled() ? null : (string) $url;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Set the url to use for a single selected list item
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ protected function setDetailUrl(Url $url): self
+ {
+ /** @var BaseHtmlElement $this */
+ $this->getAttributes()
+ ->registerAttributeCallback('data-icinga-detail-url', function () use ($url) {
+ return $this->getDetailActionsDisabled() ? null : (string) $url;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Associate the given element with the given multi-selection filter
+ *
+ * @param BaseHtmlElement $element
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function addMultiselectFilterAttribute(BaseHtmlElement $element, Filter\Rule $filter): self
+ {
+ $element->getAttributes()
+ ->registerAttributeCallback('data-icinga-multiselect-filter', function () use ($filter) {
+ if ($this->getDetailActionsDisabled()) {
+ return null;
+ }
+
+ $queryString = QueryString::render($filter);
+ if ($filter instanceof Filter\Chain) {
+ $queryString = '(' . $queryString . ')';
+ }
+
+ return $queryString;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Associate the given element with the given single-selection filter
+ *
+ * @param BaseHtmlElement $element
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function addDetailFilterAttribute(BaseHtmlElement $element, Filter\Rule $filter): self
+ {
+ $element->getAttributes()
+ ->set('data-action-item', true)
+ ->registerAttributeCallback('data-icinga-detail-filter', function () use ($filter) {
+ return $this->getDetailActionsDisabled() ? null : QueryString::render($filter);
+ });
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/HostLink.php b/library/Icingadb/Common/HostLink.php
new file mode 100644
index 0000000..3387220
--- /dev/null
+++ b/library/Icingadb/Common/HostLink.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+trait HostLink
+{
+ protected function createHostLink(Host $host, bool $withStateBall = false): BaseHtmlElement
+ {
+ $content = [];
+
+ if ($withStateBall) {
+ $content[] = new StateBall($host->state->getStateText(), StateBall::SIZE_MEDIUM);
+ $content[] = ' ';
+ }
+
+ $content[] = $host->display_name;
+
+ return Html::tag('a', ['href' => Links::host($host), 'class' => 'subject'], $content);
+ }
+}
diff --git a/library/Icingadb/Common/HostLinks.php b/library/Icingadb/Common/HostLinks.php
new file mode 100644
index 0000000..e8f2880
--- /dev/null
+++ b/library/Icingadb/Common/HostLinks.php
@@ -0,0 +1,76 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Url;
+
+abstract class HostLinks
+{
+ public static function acknowledge(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/acknowledge', ['name' => $host->name]);
+ }
+
+ public static function addComment(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/add-comment', ['name' => $host->name]);
+ }
+
+ public static function checkNow(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/check-now', ['name' => $host->name]);
+ }
+
+ public static function scheduleCheck(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/schedule-check', ['name' => $host->name]);
+ }
+
+ public static function comments(Host $host): Url
+ {
+ return Url::fromPath('icingadb/comments', ['host.name' => $host->name]);
+ }
+
+ public static function downtimes(Host $host): Url
+ {
+ return Url::fromPath('icingadb/downtimes', ['host.name' => $host->name]);
+ }
+
+ public static function history(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/history', ['name' => $host->name]);
+ }
+
+ public static function removeAcknowledgement(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/remove-acknowledgement', ['name' => $host->name]);
+ }
+
+ public static function scheduleDowntime(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/schedule-downtime', ['name' => $host->name]);
+ }
+
+ public static function sendCustomNotification(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/send-custom-notification', ['name' => $host->name]);
+ }
+
+ public static function processCheckresult(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/process-checkresult', ['name' => $host->name]);
+ }
+
+ public static function toggleFeatures(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/toggle-features', ['name' => $host->name]);
+ }
+
+ public static function services(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/services', ['name' => $host->name]);
+ }
+}
diff --git a/library/Icingadb/Common/HostStates.php b/library/Icingadb/Common/HostStates.php
new file mode 100644
index 0000000..b3a9473
--- /dev/null
+++ b/library/Icingadb/Common/HostStates.php
@@ -0,0 +1,118 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * Collection of possible host states.
+ */
+class HostStates
+{
+ const UP = 0;
+
+ const DOWN = 1;
+
+ const UNREACHABLE = 2;
+
+ const PENDING = 99;
+
+ /**
+ * Get the integer value of the given textual host state
+ *
+ * @param string $state
+ *
+ * @return int
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function int(string $state): int
+ {
+ switch (strtolower($state)) {
+ case 'up':
+ $int = self::UP;
+ break;
+ case 'down':
+ $int = self::DOWN;
+ break;
+ case 'unreachable':
+ $int = self::UNREACHABLE;
+ break;
+ case 'pending':
+ $int = self::PENDING;
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $int;
+ }
+
+ /**
+ * Get the textual representation of the passed host state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function text(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::UP:
+ $text = 'up';
+ break;
+ case $state === self::DOWN:
+ $text = 'down';
+ break;
+ case $state === self::UNREACHABLE:
+ $text = 'unreachable';
+ break;
+ case $state === self::PENDING:
+ $text = 'pending';
+ break;
+ case $state === null:
+ $text = 'not-available';
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get the translated textual representation of the passed host state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function translated(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::UP:
+ $text = t('up');
+ break;
+ case $state === self::DOWN:
+ $text = t('down');
+ break;
+ case $state === self::UNREACHABLE:
+ $text = t('unreachable');
+ break;
+ case $state === self::PENDING:
+ $text = t('pending');
+ break;
+ case $state === null:
+ $text = t('not available');
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/IcingaRedis.php b/library/Icingadb/Common/IcingaRedis.php
new file mode 100644
index 0000000..9473b8c
--- /dev/null
+++ b/library/Icingadb/Common/IcingaRedis.php
@@ -0,0 +1,236 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Predis\Client as Redis;
+
+class IcingaRedis
+{
+ /** @var static The singleton */
+ protected static $instance;
+
+ /** @var Redis Connection to the Icinga Redis */
+ private $redis;
+
+ /** @var bool true if no connection attempt was successful */
+ private $redisUnavailable = false;
+
+ /**
+ * Get the singleton
+ *
+ * @return static
+ */
+ public static function instance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new static();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get the connection to the Icinga Redis
+ *
+ * @return Redis
+ *
+ * @throws Exception
+ */
+ public function getConnection(): Redis
+ {
+ if ($this->redisUnavailable) {
+ throw new Exception('Redis is still not available');
+ } elseif ($this->redis === null) {
+ try {
+ $primaryRedis = $this->getPrimaryRedis();
+ } catch (Exception $e) {
+ try {
+ $secondaryRedis = $this->getSecondaryRedis();
+ } catch (Exception $ee) {
+ $this->redisUnavailable = true;
+ Logger::error($ee);
+
+ throw $e;
+ }
+
+ if ($secondaryRedis === null) {
+ $this->redisUnavailable = true;
+
+ throw $e;
+ }
+
+ $this->redis = $secondaryRedis;
+
+ return $this->redis;
+ }
+
+ $primaryTimestamp = $this->getLastIcingaHeartbeat($primaryRedis);
+
+ if ($primaryTimestamp <= time() - 60) {
+ $secondaryRedis = $this->getSecondaryRedis();
+
+ if ($secondaryRedis === null) {
+ $this->redis = $primaryRedis;
+
+ return $this->redis;
+ }
+
+ $secondaryTimestamp = $this->getLastIcingaHeartbeat($secondaryRedis);
+
+ if ($secondaryTimestamp > $primaryTimestamp) {
+ $this->redis = $secondaryRedis;
+ } else {
+ $this->redis = $primaryRedis;
+ }
+ } else {
+ $this->redis = $primaryRedis;
+ }
+ }
+
+ return $this->redis;
+ }
+
+ /**
+ * Get the last icinga heartbeat from redis
+ *
+ * @param Redis|null $redis
+ *
+ * @return ?float|int
+ */
+ public static function getLastIcingaHeartbeat(Redis $redis = null)
+ {
+ if ($redis === null) {
+ $redis = self::instance()->getConnection();
+ }
+
+ // Predis doesn't support streams (yet).
+ // https://github.com/predis/predis/issues/607#event-3640855190
+ $rs = $redis->executeRaw(['XREAD', 'COUNT', '1', 'STREAMS', 'icinga:stats', '0']);
+
+ if (! is_array($rs)) {
+ return null;
+ }
+
+ $key = null;
+
+ foreach ($rs[0][1][0][1] as $kv) {
+ if ($key === null) {
+ $key = $kv;
+ } else {
+ if ($key === 'timestamp') {
+ return $kv / 1000;
+ }
+
+ $key = null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the primary redis instance
+ *
+ * @param Config|null $moduleConfig
+ * @param Config|null $redisConfig
+ *
+ * @return Redis
+ */
+ public static function getPrimaryRedis(Config $moduleConfig = null, Config $redisConfig = null): Redis
+ {
+ if ($moduleConfig === null) {
+ $moduleConfig = Config::module('icingadb');
+ }
+
+ if ($redisConfig === null) {
+ $redisConfig = Config::module('icingadb', 'redis');
+ }
+
+ $section = $redisConfig->getSection('redis1');
+
+ $redis = new Redis([
+ 'host' => $section->get('host', 'localhost'),
+ 'port' => $section->get('port', 6380),
+ 'password' => $section->get('password', ''),
+ 'timeout' => 0.5
+ ] + static::getTlsParams($moduleConfig));
+
+ $redis->ping();
+
+ return $redis;
+ }
+
+ /**
+ * Get the secondary redis instance if exists
+ *
+ * @param Config|null $moduleConfig
+ * @param Config|null $redisConfig
+ *
+ * @return ?Redis
+ */
+ public static function getSecondaryRedis(Config $moduleConfig = null, Config $redisConfig = null)
+ {
+ if ($moduleConfig === null) {
+ $moduleConfig = Config::module('redis');
+ }
+
+ if ($redisConfig === null) {
+ $redisConfig = Config::module('icingadb', 'redis');
+ }
+
+ $section = $redisConfig->getSection('redis2');
+ $host = $section->host;
+
+ if (empty($host)) {
+ return null;
+ }
+
+ $redis = new Redis([
+ 'host' => $host,
+ 'port' => $section->get('port', 6380),
+ 'password' => $section->get('password', ''),
+ 'timeout' => 0.5
+ ] + static::getTlsParams($moduleConfig));
+
+ $redis->ping();
+
+ return $redis;
+ }
+
+ private static function getTlsParams(Config $config): array
+ {
+ $config = $config->getSection('redis');
+
+ if (! $config->get('tls', false)) {
+ return [];
+ }
+
+ $ssl = [];
+
+ if ($config->get('insecure')) {
+ $ssl['verify_peer'] = false;
+ $ssl['verify_peer_name'] = false;
+ } else {
+ $ca = $config->get('ca');
+
+ if ($ca !== null) {
+ $ssl['cafile'] = $ca;
+ }
+ }
+
+ $cert = $config->get('cert');
+ $key = $config->get('key');
+
+ if ($cert !== null && $key !== null) {
+ $ssl['local_cert'] = $cert;
+ $ssl['local_pk'] = $key;
+ }
+
+ return ['scheme' => 'tls', 'ssl' => $ssl];
+ }
+}
diff --git a/library/Icingadb/Common/Icons.php b/library/Icingadb/Common/Icons.php
new file mode 100644
index 0000000..cac9f32
--- /dev/null
+++ b/library/Icingadb/Common/Icons.php
@@ -0,0 +1,30 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+class Icons
+{
+ const COMMENT = 'comment';
+
+ const HOST_DOWN = 'sitemap';
+
+ const IN_DOWNTIME = 'plug';
+
+ const IS_ACKNOWLEDGED = 'check';
+
+ const IS_FLAPPING = 'bolt';
+
+ const IS_PERSISTENT = 'thumbtack';
+
+ const NOTIFICATION = 'bell';
+
+ const REMOVE = 'trash';
+
+ const USER = 'user';
+
+ const USERGROUP = 'users';
+
+ const WARNING = 'exclamation-triangle';
+}
diff --git a/library/Icingadb/Common/Links.php b/library/Icingadb/Common/Links.php
new file mode 100644
index 0000000..5968e5f
--- /dev/null
+++ b/library/Icingadb/Common/Links.php
@@ -0,0 +1,143 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use ipl\Web\Url;
+
+abstract class Links
+{
+ public static function comment(Comment $comment): Url
+ {
+ return Url::fromPath('icingadb/comment', ['name' => $comment->name]);
+ }
+
+ public static function comments(): Url
+ {
+ return Url::fromPath('icingadb/comments');
+ }
+
+ public static function commentsDelete(): Url
+ {
+ return Url::fromPath('icingadb/comments/delete');
+ }
+
+ public static function commentsDetails(): Url
+ {
+ return Url::fromPath('icingadb/comments/details');
+ }
+
+ public static function downtime(Downtime $downtime): Url
+ {
+ return Url::fromPath('icingadb/downtime', ['name' => $downtime->name]);
+ }
+
+ public static function downtimes(): Url
+ {
+ return Url::fromPath('icingadb/downtimes');
+ }
+
+ public static function downtimesDelete(): Url
+ {
+ return Url::fromPath('icingadb/downtimes/delete');
+ }
+
+ public static function downtimesDetails(): Url
+ {
+ return Url::fromPath('icingadb/downtimes/details');
+ }
+
+ public static function host(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host', ['name' => $host->name]);
+ }
+
+ public static function hostSource(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/source', ['name' => $host->name]);
+ }
+
+ public static function hostsDetails(): Url
+ {
+ return Url::fromPath('icingadb/hosts/details');
+ }
+
+ public static function hostgroup($hostgroup): Url
+ {
+ return Url::fromPath('icingadb/hostgroup', ['name' => $hostgroup->name]);
+ }
+
+ public static function hosts(): Url
+ {
+ return Url::fromPath('icingadb/hosts');
+ }
+
+ public static function service(Service $service, Host $host): Url
+ {
+ return Url::fromPath('icingadb/service', ['name' => $service->name, 'host.name' => $host->name]);
+ }
+
+ public static function serviceSource(Service $service, Host $host): Url
+ {
+ return Url::fromPath('icingadb/service/source', ['name' => $service->name, 'host.name' => $host->name]);
+ }
+
+ public static function servicesDetails(): Url
+ {
+ return Url::fromPath('icingadb/services/details');
+ }
+
+ public static function servicegroup($servicegroup): Url
+ {
+ return Url::fromPath('icingadb/servicegroup', ['name' => $servicegroup->name]);
+ }
+
+ public static function services(): Url
+ {
+ return Url::fromPath('icingadb/services');
+ }
+
+ public static function toggleHostsFeatures(): Url
+ {
+ return Url::fromPath('icingadb/hosts/toggle-features');
+ }
+
+ public static function toggleServicesFeatures(): Url
+ {
+ return Url::fromPath('icingadb/services/toggle-features');
+ }
+
+ public static function user(User $user): Url
+ {
+ return Url::fromPath('icingadb/user', ['name' => $user->name]);
+ }
+
+ public static function usergroup(Usergroup $usergroup): Url
+ {
+ return Url::fromPath('icingadb/usergroup', ['name' => $usergroup->name]);
+ }
+
+ public static function users(): Url
+ {
+ return Url::fromPath('icingadb/users');
+ }
+
+ public static function usergroups(): Url
+ {
+ return Url::fromPath('icingadb/usergroups');
+ }
+
+ public static function event(History $event): Url
+ {
+ return Url::fromPath('icingadb/event', ['id' => bin2hex($event->id)]);
+ }
+}
diff --git a/library/Icingadb/Common/ListItemCommonLayout.php b/library/Icingadb/Common/ListItemCommonLayout.php
new file mode 100644
index 0000000..4777c7c
--- /dev/null
+++ b/library/Icingadb/Common/ListItemCommonLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemCommonLayout
+{
+ use CaptionDisabled;
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $header->add($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main)
+ {
+ $main->add($this->createHeader());
+ if (! $this->isCaptionDisabled()) {
+ $main->add($this->createCaption());
+ }
+ }
+}
diff --git a/library/Icingadb/Common/ListItemDetailedLayout.php b/library/Icingadb/Common/ListItemDetailedLayout.php
new file mode 100644
index 0000000..3db91a3
--- /dev/null
+++ b/library/Icingadb/Common/ListItemDetailedLayout.php
@@ -0,0 +1,23 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemDetailedLayout
+{
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $header->add($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main)
+ {
+ $main->add($this->createHeader());
+ $main->add($this->createCaption());
+ $main->add($this->createFooter());
+ }
+}
diff --git a/library/Icingadb/Common/ListItemMinimalLayout.php b/library/Icingadb/Common/ListItemMinimalLayout.php
new file mode 100644
index 0000000..9b0dc5b
--- /dev/null
+++ b/library/Icingadb/Common/ListItemMinimalLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemMinimalLayout
+{
+ use CaptionDisabled;
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $header->add($this->createTitle());
+ if (! $this->isCaptionDisabled()) {
+ $header->add($this->createCaption());
+ }
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main)
+ {
+ $main->add($this->createHeader());
+ }
+}
diff --git a/library/Icingadb/Common/LoadMore.php b/library/Icingadb/Common/LoadMore.php
new file mode 100644
index 0000000..c9ef0a2
--- /dev/null
+++ b/library/Icingadb/Common/LoadMore.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Generator;
+use Icinga\Module\Icingadb\Widget\ItemList\PageSeparatorItem;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Orm\ResultSet;
+use ipl\Web\Url;
+
+trait LoadMore
+{
+ /** @var int */
+ protected $pageSize;
+
+ /** @var int */
+ protected $pageNumber;
+
+ /** @var Url */
+ protected $loadMoreUrl;
+
+ /**
+ * Set the page size
+ *
+ * @param int $size
+ *
+ * @return $this
+ */
+ public function setPageSize(int $size): self
+ {
+ $this->pageSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the page number
+ *
+ * @param int $number
+ *
+ * @return $this
+ */
+ public function setPageNumber(int $number): self
+ {
+ $this->pageNumber = $number;
+
+ return $this;
+ }
+
+ /**
+ * Set the url to fetch more items
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setLoadMoreUrl(Url $url): self
+ {
+ $this->loadMoreUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Iterate over the given data
+ *
+ * Add the page separator and the "LoadMore" button at the desired position
+ *
+ * @param ResultSet $result
+ *
+ * @return Generator
+ */
+ protected function getIterator(ResultSet $result): Generator
+ {
+ $count = 0;
+ $pageNumber = $this->pageNumber ?: 1;
+
+ if ($pageNumber > 1) {
+ $this->add(new PageSeparatorItem($pageNumber));
+ }
+
+ foreach ($result as $data) {
+ $count++;
+
+ if ($count % $this->pageSize === 0) {
+ $pageNumber++;
+ } elseif ($count > $this->pageSize && $count % $this->pageSize === 1) {
+ $this->add(new PageSeparatorItem($pageNumber));
+ }
+
+ yield $data;
+ }
+
+ if ($count > 0 && $this->loadMoreUrl !== null) {
+ $showMore = (new ShowMore(
+ $result,
+ $this->loadMoreUrl->setParam('page', $pageNumber)
+ ->setAnchor('page-' . ($pageNumber))
+ ))
+ ->setLabel(t('Load More'))
+ ->setAttribute('data-no-icinga-ajax', true);
+
+ $this->add($showMore->setTag('li')->addAttributes(['class' => 'list-item']));
+ }
+ }
+}
diff --git a/library/Icingadb/Common/Macros.php b/library/Icingadb/Common/Macros.php
new file mode 100644
index 0000000..733c116
--- /dev/null
+++ b/library/Icingadb/Common/Macros.php
@@ -0,0 +1,118 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\ResultSet;
+
+use function ipl\Stdlib\get_php_type;
+
+trait Macros
+{
+ /**
+ * Get the given string with macros being resolved
+ *
+ * @param string $input The string in which to look for macros
+ * @param Model $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function expandMacros(string $input, Model $object): string
+ {
+ if (preg_match_all('@\$([^\$\s]+)\$@', $input, $matches)) {
+ foreach ($matches[1] as $key => $value) {
+ $newValue = $this->resolveMacro($value, $object);
+ if ($newValue !== $value) {
+ $input = str_replace($matches[0][$key], $newValue, $input);
+ }
+ }
+ }
+
+ return $input;
+ }
+
+ /**
+ * Resolve a macro based on the given object
+ *
+ * @param string $macro The macro to resolve
+ * @param Model $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function resolveMacro(string $macro, Model $object): string
+ {
+ if ($object instanceof Host) {
+ $objectType = 'host';
+ } else {
+ $objectType = 'service';
+ }
+
+ $path = null;
+ $macroType = $objectType;
+ $isCustomVar = false;
+ if (preg_match('/^((host|service)\.)?vars\.(.+)/', $macro, $matches)) {
+ if (! empty($matches[2])) {
+ $macroType = $matches[2];
+ }
+
+ $path = $matches[3];
+ $isCustomVar = true;
+ } elseif (preg_match('/^(\w+)\.(.+)/', $macro, $matches)) {
+ $macroType = $matches[1];
+ $path = $matches[2];
+ }
+
+ try {
+ if ($path !== null) {
+ if ($macroType !== $objectType) {
+ $value = $object->$macroType;
+ } else {
+ $value = $object;
+ }
+
+ $properties = explode('.', $path);
+
+ do {
+ $column = array_shift($properties);
+ if ($value instanceof Query || $value instanceof ResultSet || is_array($value)) {
+ Logger::debug(
+ 'Failed to resolve property "%s" on a "%s" type.',
+ $isCustomVar ? 'vars' : $column,
+ get_php_type($value)
+ );
+ $value = null;
+ break;
+ }
+
+ if ($isCustomVar) {
+ $value = $value->vars[$path];
+ break;
+ }
+
+ $value = $value->$column;
+ } while (! empty($properties) && $value !== null);
+ } else {
+ $value = $object->$macro;
+ }
+ } catch (\Exception $e) {
+ $value = null;
+ Logger::debug('Unable to resolve macro "%s". An error occurred: %s', $macro, $e);
+ }
+
+ if ($value instanceof Query || $value instanceof ResultSet || is_array($value)) {
+ Logger::debug(
+ 'It is not allowed to use "%s" as a macro which produces a "%s" type as a result.',
+ $macro,
+ get_php_type($value)
+ );
+ $value = null;
+ }
+
+ return $value !== null ? $value : $macro;
+ }
+}
diff --git a/library/Icingadb/Common/NoSubjectLink.php b/library/Icingadb/Common/NoSubjectLink.php
new file mode 100644
index 0000000..76c9a84
--- /dev/null
+++ b/library/Icingadb/Common/NoSubjectLink.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait NoSubjectLink
+{
+ /** @var bool */
+ protected $noSubjectLink = false;
+
+ /**
+ * Set whether a list item's subject should be a link
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setNoSubjectLink(bool $state = true): self
+ {
+ $this->noSubjectLink = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether a list item's subject should be a link
+ *
+ * @return bool
+ */
+ public function getNoSubjectLink(): bool
+ {
+ return $this->noSubjectLink;
+ }
+}
diff --git a/library/Icingadb/Common/ObjectInspectionDetail.php b/library/Icingadb/Common/ObjectInspectionDetail.php
new file mode 100644
index 0000000..87c9b52
--- /dev/null
+++ b/library/Icingadb/Common/ObjectInspectionDetail.php
@@ -0,0 +1,330 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use DateTime;
+use DateTimeZone;
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Widget\Detail\CustomVarTable;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Util\Format;
+use Icinga\Util\Json;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+
+abstract class ObjectInspectionDetail extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'inspection-detail']];
+
+ /** @var Model */
+ protected $object;
+
+ /** @var array */
+ protected $attrs;
+
+ /** @var array */
+ protected $joins;
+
+ public function __construct(Model $object, array $apiResult)
+ {
+ $this->object = $object;
+ $this->attrs = $apiResult['attrs'];
+ $this->joins = $apiResult['joins'];
+ }
+
+ /**
+ * Render the object source location
+ *
+ * @return ?array
+ */
+ protected function createSourceLocation()
+ {
+ if (! isset($this->attrs['source_location'])) {
+ return;
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Source Location'))),
+ FormattedString::create(
+ t('You can find this object in %s on line %s.'),
+ new HtmlElement('strong', null, Text::create($this->attrs['source_location']['path'])),
+ new HtmlElement('strong', null, Text::create($this->attrs['source_location']['first_line']))
+ )
+ ];
+ }
+
+ /**
+ * Render object's last check result
+ *
+ * @return ?array
+ */
+ protected function createLastCheckResult()
+ {
+ if (! isset($this->attrs['last_check_result'])) {
+ return;
+ }
+
+ $command = $this->attrs['last_check_result']['command'];
+ if (is_array($command)) {
+ $command = join(' ', array_map('escapeshellarg', $command));
+ }
+
+ $denylist = [
+ 'command',
+ 'output',
+ 'type',
+ 'active'
+ ];
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Executed Command'))),
+ new HtmlElement('pre', null, Text::create($command)),
+ new HtmlElement('h2', null, Text::create(t('Execution Details'))),
+ $this->createNameValueTable(
+ array_diff_key($this->attrs['last_check_result'], array_flip($denylist)),
+ [
+ 'execution_end' => [$this, 'formatTimestamp'],
+ 'execution_start' => [$this, 'formatTimestamp'],
+ 'schedule_end' => [$this, 'formatTimestamp'],
+ 'schedule_start' => [$this, 'formatTimestamp'],
+ 'ttl' => [$this, 'formatSeconds'],
+ 'state' => [$this, 'formatState']
+ ]
+ )
+ ];
+ }
+
+ protected function createRedisInfo(): array
+ {
+ $title = new HtmlElement('h2', null, Text::create(t('Volatile State Details')));
+
+ try {
+ $json = IcingaRedis::instance()->getConnection()
+ ->hGet("icinga:{$this->object->getTableName()}:state", bin2hex($this->object->id));
+ } catch (Exception $e) {
+ return [$title, sprintf('Failed to load redis data: %s', $e->getMessage())];
+ }
+
+ if (! $json) {
+ return [$title, new EmptyState(t('No data available in redis'))];
+ }
+
+ try {
+ $data = Json::decode($json, true);
+ } catch (JsonDecodeException $e) {
+ return [$title, sprintf('Failed to decode redis data: %s', $e->getMessage())];
+ }
+
+ $denylist = [
+ 'commandline',
+ 'environment_id',
+ 'id'
+ ];
+
+ return [$title, $this->createNameValueTable(
+ array_diff_key($data, array_flip($denylist)),
+ [
+ 'last_state_change' => [$this, 'formatMillisecondTimestamp'],
+ 'last_update' => [$this, 'formatMillisecondTimestamp'],
+ 'next_check' => [$this, 'formatMillisecondTimestamp'],
+ 'next_update' => [$this, 'formatMillisecondTimestamp'],
+ 'check_timeout' => [$this, 'formatMilliseconds'],
+ 'execution_time' => [$this, 'formatMilliseconds'],
+ 'latency' => [$this, 'formatMilliseconds'],
+ 'hard_state' => [$this, 'formatState'],
+ 'previous_soft_state' => [$this, 'formatState'],
+ 'previous_hard_state' => [$this, 'formatState'],
+ 'state' => [$this, 'formatState']
+ ]
+ )];
+ }
+
+ protected function createAttributes(): array
+ {
+ $denylist = [
+ 'name',
+ '__name',
+ 'host_name',
+ 'display_name',
+ 'last_check_result',
+ 'source_location',
+ 'templates',
+ 'package',
+ 'version',
+ 'type',
+ 'active',
+ 'paused',
+ 'ha_mode'
+ ];
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Object Attributes'))),
+ $this->createNameValueTable(
+ array_diff_key($this->attrs, array_flip($denylist)),
+ [
+ 'acknowledgement_expiry' => [$this, 'formatTimestamp'],
+ 'acknowledgement_last_change' => [$this, 'formatTimestamp'],
+ 'check_timeout' => [$this, 'formatSeconds'],
+ 'flapping_last_change' => [$this, 'formatTimestamp'],
+ 'last_check' => [$this, 'formatTimestamp'],
+ 'last_hard_state_change' => [$this, 'formatTimestamp'],
+ 'last_state_change' => [$this, 'formatTimestamp'],
+ 'last_state_ok' => [$this, 'formatTimestamp'],
+ 'last_state_up' => [$this, 'formatTimestamp'],
+ 'last_state_warning' => [$this, 'formatTimestamp'],
+ 'last_state_critical' => [$this, 'formatTimestamp'],
+ 'last_state_down' => [$this, 'formatTimestamp'],
+ 'last_state_unknown' => [$this, 'formatTimestamp'],
+ 'last_state_unreachable' => [$this, 'formatTimestamp'],
+ 'next_check' => [$this, 'formatTimestamp'],
+ 'next_update' => [$this, 'formatTimestamp'],
+ 'previous_state_change' => [$this, 'formatTimestamp'],
+ 'check_interval' => [$this, 'formatSeconds'],
+ 'retry_interval' => [$this, 'formatSeconds'],
+ 'last_hard_state' => [$this, 'formatState'],
+ 'last_state' => [$this, 'formatState'],
+ 'state' => [$this, 'formatState']
+ ]
+ )
+ ];
+ }
+
+ protected function createCustomVariables()
+ {
+ $query = $this->object->customvar
+ ->columns(['name', 'value']);
+
+ $result = [];
+ foreach ($query as $row) {
+ $result[$row->name] = json_decode($row->value, true) ?? $row->value;
+ }
+
+ if (! empty($result)) {
+ $vars = new CustomVarTable($result);
+ } else {
+ $vars = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Custom Variables'))),
+ $vars
+ ];
+ }
+
+ /**
+ * Format the given value as a json
+ *
+ * @param mixed $json
+ *
+ * @return BaseHtmlElement|string
+ */
+ private function formatJson($json)
+ {
+ if (is_scalar($json)) {
+ return Json::encode($json, JSON_UNESCAPED_SLASHES);
+ }
+
+ return new HtmlElement(
+ 'pre',
+ null,
+ Text::create(Json::encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
+ );
+ }
+
+ /**
+ * Format the given timestamp
+ *
+ * @param int|float|null $ts
+ *
+ * @return EmptyState|string
+ */
+ private function formatTimestamp($ts)
+ {
+ if (empty($ts)) {
+ return new EmptyState(t('n. a.'));
+ }
+
+ if (is_float($ts)) {
+ $dt = DateTime::createFromFormat('U.u', sprintf('%F', $ts));
+ } else {
+ $dt = (new DateTime())->setTimestamp($ts);
+ }
+
+ return $dt->setTimezone(new DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s.vP');
+ }
+
+ /**
+ * Format the given timestamp (in milliseconds)
+ *
+ * @param int|float|null $ms
+ *
+ * @return EmptyState|string
+ */
+ private function formatMillisecondTimestamp($ms)
+ {
+ return $this->formatTimestamp($ms / 1000.0);
+ }
+
+ private function formatSeconds($s): string
+ {
+ return Format::seconds($s);
+ }
+
+ private function formatMilliseconds($ms): string
+ {
+ return Format::seconds($ms / 1000.0);
+ }
+
+ private function formatState(int $state)
+ {
+ switch (true) {
+ case $this->object instanceof Host:
+ return HostStates::text($state);
+ case $this->object instanceof Service:
+ return ServiceStates::text($state);
+ default:
+ return $state;
+ }
+ }
+
+ private function createNameValueTable(array $data, array $formatters): Table
+ {
+ $table = new Table();
+ $table->addAttributes(['class' => 'name-value-table']);
+ foreach ($data as $name => $value) {
+ if (empty($value) && ($value === null || is_string($value) || is_array($value))) {
+ $value = new EmptyState(t('n. a.'));
+ } else {
+ try {
+ if (isset($formatters[$name])) {
+ $value = call_user_func($formatters[$name], $value);
+ } else {
+ $value = $this->formatJson($value);
+ }
+ } catch (Exception $e) {
+ $value = new EmptyState(IcingaException::describe($e));
+ }
+ }
+
+ $table->addHtml(Table::tr([
+ Table::th($name),
+ Table::td($value)
+ ]));
+ }
+
+ return $table;
+ }
+}
diff --git a/library/Icingadb/Common/ObjectLinkDisabled.php b/library/Icingadb/Common/ObjectLinkDisabled.php
new file mode 100644
index 0000000..ca8283f
--- /dev/null
+++ b/library/Icingadb/Common/ObjectLinkDisabled.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait ObjectLinkDisabled
+{
+ /** @var bool */
+ protected $objectLinkDisabled = false;
+
+ /**
+ * Set whether list items should render host and service links
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setObjectLinkDisabled(bool $state = true): self
+ {
+ $this->objectLinkDisabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return bool
+ */
+ public function getObjectLinkDisabled(): bool
+ {
+ return $this->objectLinkDisabled;
+ }
+}
diff --git a/library/Icingadb/Common/SearchControls.php b/library/Icingadb/Common/SearchControls.php
new file mode 100644
index 0000000..7927da0
--- /dev/null
+++ b/library/Icingadb/Common/SearchControls.php
@@ -0,0 +1,69 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use ipl\Html\Html;
+use ipl\Orm\Query;
+use ipl\Web\Control\SearchBar;
+use ipl\Web\Url;
+use ipl\Web\Widget\ContinueWith;
+
+trait SearchControls
+{
+ use \ipl\Web\Compat\SearchControls {
+ \ipl\Web\Compat\SearchControls::createSearchBar as private webCreateSearchBar;
+ }
+
+ public function fetchFilterColumns(Query $query): array
+ {
+ return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver()));
+ }
+
+ /**
+ * Create and return the SearchBar
+ *
+ * @param Query $query The query being filtered
+ * @param Url $redirectUrl Url to redirect to upon success
+ * @param array $preserveParams Query params to preserve when redirecting
+ *
+ * @return SearchBar
+ */
+ public function createSearchBar(Query $query, ...$params): SearchBar
+ {
+ $searchBar = $this->webCreateSearchBar($query, ...$params);
+
+ if (($wrapper = $searchBar->getWrapper()) && ! $wrapper->getWrapper()) {
+ // TODO: Remove this once ipl-web v0.7.0 is required
+ $searchBar->addWrapper(Html::tag('div', ['class' => 'search-controls']));
+ }
+
+ return $searchBar;
+ }
+
+ /**
+ * Create and return a ContinueWith
+ *
+ * This will automatically be appended to the SearchBar's wrapper. It's not necessary
+ * to add it separately as control or content!
+ *
+ * @param Url $detailsUrl
+ * @param SearchBar $searchBar
+ *
+ * @return ContinueWith
+ */
+ public function createContinueWith(Url $detailsUrl, SearchBar $searchBar): ContinueWith
+ {
+ $continueWith = new ContinueWith($detailsUrl, [$searchBar, 'getFilter']);
+ $continueWith->setTitle(t('Show bulk processing actions for all filtered results'));
+ $continueWith->setBaseTarget('_next');
+ $continueWith->getAttributes()
+ ->set('id', $this->getRequest()->protectId('continue-with'));
+
+ $searchBar->getWrapper()->add($continueWith);
+
+ return $continueWith;
+ }
+}
diff --git a/library/Icingadb/Common/ServiceLink.php b/library/Icingadb/Common/ServiceLink.php
new file mode 100644
index 0000000..75ac6c6
--- /dev/null
+++ b/library/Icingadb/Common/ServiceLink.php
@@ -0,0 +1,40 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\FormattedString;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+trait ServiceLink
+{
+ protected function createServiceLink(Service $service, Host $host, bool $withStateBall = false): FormattedString
+ {
+ $content = [];
+
+ if ($withStateBall) {
+ $content[] = new StateBall($service->state->getStateText(), StateBall::SIZE_MEDIUM);
+ $content[] = ' ';
+ }
+
+ $content[] = $service->display_name;
+
+ return Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ Html::tag('a', ['href' => Links::service($service, $host), 'class' => 'subject'], $content),
+ Html::tag(
+ 'a',
+ ['href' => Links::host($host), 'class' => 'subject'],
+ [
+ new StateBall($host->state->getStateText(), StateBall::SIZE_MEDIUM),
+ ' ',
+ $host->display_name
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Common/ServiceLinks.php b/library/Icingadb/Common/ServiceLinks.php
new file mode 100644
index 0000000..368be48
--- /dev/null
+++ b/library/Icingadb/Common/ServiceLinks.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Url;
+
+abstract class ServiceLinks
+{
+ public static function acknowledge(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/acknowledge',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function addComment(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/add-comment',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function checkNow(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/check-now',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function scheduleCheck(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/schedule-check',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function comments(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/comments',
+ ['service.name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function downtimes(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/downtimes',
+ ['service.name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function history(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/history',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function removeAcknowledgement(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/remove-acknowledgement',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function scheduleDowntime(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/schedule-downtime',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function sendCustomNotification(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/send-custom-notification',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function processCheckresult(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/process-checkresult',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function toggleFeatures(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/toggle-features',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+}
diff --git a/library/Icingadb/Common/ServiceStates.php b/library/Icingadb/Common/ServiceStates.php
new file mode 100644
index 0000000..526f95e
--- /dev/null
+++ b/library/Icingadb/Common/ServiceStates.php
@@ -0,0 +1,129 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * Collection of possible service states.
+ */
+class ServiceStates
+{
+ const OK = 0;
+
+ const WARNING = 1;
+
+ const CRITICAL = 2;
+
+ const UNKNOWN = 3;
+
+ const PENDING = 99;
+
+ /**
+ * Get the integer value of the given textual service state
+ *
+ * @param string $state
+ *
+ * @return int
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function int(string $state): int
+ {
+ switch (strtolower($state)) {
+ case 'ok':
+ $int = self::OK;
+ break;
+ case 'warning':
+ $int = self::WARNING;
+ break;
+ case 'critical':
+ $int = self::CRITICAL;
+ break;
+ case 'unknown':
+ $int = self::UNKNOWN;
+ break;
+ case 'pending':
+ $int = self::PENDING;
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $int;
+ }
+
+ /**
+ * Get the textual representation of the passed service state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function text(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::OK:
+ $text = 'ok';
+ break;
+ case $state === self::WARNING:
+ $text = 'warning';
+ break;
+ case $state === self::CRITICAL:
+ $text = 'critical';
+ break;
+ case $state === self::UNKNOWN:
+ $text = 'unknown';
+ break;
+ case $state === self::PENDING:
+ $text = 'pending';
+ break;
+ case $state === null:
+ $text = 'not-available';
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get the translated textual representation of the passed service state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function translated(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::OK:
+ $text = t('ok');
+ break;
+ case $state === self::WARNING:
+ $text = t('warning');
+ break;
+ case $state === self::CRITICAL:
+ $text = t('critical');
+ break;
+ case $state === self::UNKNOWN:
+ $text = t('unknown');
+ break;
+ case $state === self::PENDING:
+ $text = t('pending');
+ break;
+ case $state === null:
+ $text = t('not available');
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/StateBadges.php b/library/Icingadb/Common/StateBadges.php
new file mode 100644
index 0000000..2cb5cc8
--- /dev/null
+++ b/library/Icingadb/Common/StateBadges.php
@@ -0,0 +1,185 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Widget\StateBadge;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+abstract class StateBadges extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var object $item */
+ protected $item;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string Prefix */
+ protected $prefix;
+
+ /** @var Url Badge link */
+ protected $url;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'state-badges'];
+
+ /**
+ * Create a new widget for state badges
+ *
+ * @param object $item
+ */
+ public function __construct($item)
+ {
+ $this->item = $item;
+ $this->type = $this->getType();
+ $this->prefix = $this->getPrefix();
+ $this->url = $this->getBaseUrl();
+ }
+
+ /**
+ * Get the badge base URL
+ *
+ * @return Url
+ */
+ abstract protected function getBaseUrl(): Url;
+
+ /**
+ * Get the type of the items
+ *
+ * @return string
+ */
+ abstract protected function getType(): string;
+
+ /**
+ * Get the prefix for accessing state information
+ *
+ * @return string
+ */
+ abstract protected function getPrefix(): string;
+
+ /**
+ * Get the integer of the given state text
+ *
+ * @param string $state
+ *
+ * @return int
+ */
+ abstract protected function getStateInt(string $state): int;
+
+ /**
+ * Get the badge URL
+ *
+ * @return Url
+ */
+ public function getUrl(): Url
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the badge URL
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setUrl(Url $url): self
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Create a badge link
+ *
+ * @param $content
+ * @param array $params
+ *
+ * @return Link
+ */
+ public function createLink($content, array $params = null): Link
+ {
+ $url = clone $this->getUrl();
+
+ if (! empty($params)) {
+ $url->getParams()->mergeValues($params);
+ }
+
+ if ($this->hasBaseFilter()) {
+ $url->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ return new Link($content, $url);
+ }
+
+ /**
+ * Create a state bade
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createBadge(string $state)
+ {
+ $key = $this->prefix . "_{$state}";
+
+ if (isset($this->item->$key) && $this->item->$key) {
+ return Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$key, $state),
+ [$this->type . '.state.soft_state' => $this->getStateInt($state)]
+ ));
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a state group
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createGroup(string $state)
+ {
+ $content = [];
+ $handledKey = $this->prefix . "_{$state}_handled";
+ $unhandledKey = $this->prefix . "_{$state}_unhandled";
+
+ if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$unhandledKey, $state),
+ [
+ $this->type . '.state.soft_state' => $this->getStateInt($state),
+ $this->type . '.state.is_handled' => 'n'
+ ]
+ ));
+ }
+
+ if (isset($this->item->$handledKey) && $this->item->$handledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$handledKey, $state, true),
+ [
+ $this->type . '.state.soft_state' => $this->getStateInt($state),
+ $this->type . '.state.is_handled' => 'y'
+ ]
+ ));
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ return Html::tag('li', Html::tag('ul', $content));
+ }
+}
diff --git a/library/Icingadb/Common/TicketLinks.php b/library/Icingadb/Common/TicketLinks.php
new file mode 100644
index 0000000..6cf7e76
--- /dev/null
+++ b/library/Icingadb/Common/TicketLinks.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Hook;
+
+trait TicketLinks
+{
+ /** @var bool */
+ protected $ticketLinkEnabled = false;
+
+ /**
+ * Set whether list items should render host and service links
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setTicketLinkEnabled(bool $state = true): self
+ {
+ $this->ticketLinkEnabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return bool
+ */
+ public function getTicketLinkEnabled(): bool
+ {
+ return $this->ticketLinkEnabled;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return string
+ */
+ public function createTicketLinks($text): string
+ {
+ if (Hook::has('ticket')) {
+ $tickets = Hook::first('ticket');
+ }
+
+ if ($this->getTicketLinkEnabled() && isset($tickets)) {
+ /** @var \Icinga\Application\Hook\TicketHook $tickets */
+ return $tickets->createLinks($text);
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/ViewMode.php b/library/Icingadb/Common/ViewMode.php
new file mode 100644
index 0000000..841f28b
--- /dev/null
+++ b/library/Icingadb/Common/ViewMode.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait ViewMode
+{
+ /** @var string */
+ protected $viewMode;
+
+ /**
+ * Get the view mode
+ *
+ * @return ?string
+ */
+ public function getViewMode()
+ {
+ return $this->viewMode;
+ }
+
+ /**
+ * Set the view mode
+ *
+ * @param string $viewMode
+ *
+ * @return $this
+ */
+ public function setViewMode(string $viewMode): self
+ {
+ $this->viewMode = $viewMode;
+
+ return $this;
+ }
+}