diff options
Diffstat (limited to 'library/Icingadb/Common')
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; + } +} |