diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:44:46 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:44:46 +0000 |
commit | b18bc644404e02b57635bfcc8258e85abb141146 (patch) | |
tree | 686512eacb2dba0055277ef7ec2f28695b3418ea /library | |
parent | Initial commit. (diff) | |
download | icingadb-web-b18bc644404e02b57635bfcc8258e85abb141146.tar.xz icingadb-web-b18bc644404e02b57635bfcc8258e85abb141146.zip |
Adding upstream version 1.1.1.upstream/1.1.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
289 files changed, 29194 insertions, 0 deletions
diff --git a/library/Icingadb/Authentication/ObjectAuthorization.php b/library/Icingadb/Authentication/ObjectAuthorization.php new file mode 100644 index 0000000..988e8f0 --- /dev/null +++ b/library/Icingadb/Authentication/ObjectAuthorization.php @@ -0,0 +1,261 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Authentication; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use InvalidArgumentException; +use ipl\Orm\Compat\FilterProcessor; +use ipl\Orm\Model; +use ipl\Sql\Expression; +use ipl\Sql\Select; +use ipl\Stdlib\Filter; + +class ObjectAuthorization +{ + use Auth; + use Database; + + /** @var array */ + protected static $knownGrants = []; + + /** + * Caches already applied filters to an object + * + * @var array + */ + protected static $matchedFilters = []; + + /** + * Check whether the permission is granted on the object + * + * @param string $permission + * @param Model $for The object + * + * @return bool + */ + public static function grantsOn(string $permission, Model $for): bool + { + $self = new static(); + + $tableName = $for->getTableName(); + $uniqueId = $for->{$for->getKeyName()}; + if (! isset($uniqueId)) { + return false; + } + + if (! isset(self::$knownGrants[$tableName][$uniqueId])) { + $self->loadGrants( + get_class($for), + Filter::equal($for->getKeyName(), $uniqueId), + $uniqueId, + false + ); + } + + return $self->checkGrants($permission, self::$knownGrants[$tableName][$uniqueId]); + } + + /** + * 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 ObjectAuthorization::grantsOn} + * 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 static function grantsOnType(string $permission, string $type, Filter\Rule $filter, bool $cache = true): bool + { + switch ($type) { + case 'host': + $for = Host::class; + break; + case 'service': + $for = Service::class; + break; + default: + throw new InvalidArgumentException(sprintf('Unknown type "%s"', $type)); + } + + $self = new static(); + + $uniqueId = spl_object_hash($filter); + if (! isset(self::$knownGrants[$type][$uniqueId])) { + $self->loadGrants($for, $filter, $uniqueId, $cache); + } + + return $self->checkGrants($permission, self::$knownGrants[$type][$uniqueId]); + } + + /** + * Check whether the given filter matches on the given object + * + * @param string $queryString + * @param Model $object + * + * @return bool + */ + public static function matchesOn(string $queryString, Model $object): bool + { + $self = new static(); + + $uniqueId = $object->{$object->getKeyName()}; + if (! isset(self::$matchedFilters[$queryString][$uniqueId])) { + $restriction = 'icingadb/filter/services'; + if ($object instanceof Host) { + $restriction = 'icingadb/filter/hosts'; + } + + $filter = $self->parseRestriction($queryString, $restriction); + + $query = $object::on($self->getDb()); + $query + ->filter($filter) + ->filter(Filter::equal($object->getKeyName(), $uniqueId)) + ->columns([new Expression('1')]); + + $result = $query->execute()->hasResult(); + self::$matchedFilters[$queryString][$uniqueId] = $result; + + return $result; + } + + return self::$matchedFilters[$queryString][$uniqueId]; + } + + /** + * Load all the user's roles that grant access to at least one object matching the filter + * + * @param string $model The class path to the object model + * @param Filter\Rule $filter + * @param string $cacheKey + * @param bool $cache Pass `false` to not populate the cache with the matching objects + * + * @return void + */ + protected function loadGrants(string $model, Filter\Rule $filter, string $cacheKey, bool $cache = true) + { + /** @var Model $model */ + $query = $model::on($this->getDb()); + $tableName = $query->getModel()->getTableName(); + + $inspectedRoles = []; + $roleExpressions = []; + $rolesWithoutRestrictions = []; + + foreach ($this->getAuth()->getUser()->getRoles() as $role) { + $roleFilter = Filter::all(); + if (($restriction = $role->getRestrictions('icingadb/filter/objects'))) { + $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/objects')); + } + + if ($tableName === 'host' || $tableName === 'service') { + if (($restriction = $role->getRestrictions('icingadb/filter/hosts'))) { + $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/hosts')); + } + } + + if ($tableName === 'service' && ($restriction = $role->getRestrictions('icingadb/filter/services'))) { + $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/services')); + } + + if ($roleFilter->isEmpty()) { + $rolesWithoutRestrictions[] = $role->getName(); + continue; + } + + $roleName = str_replace('.', '_', $role->getName()); + $inspectedRoles[$roleName] = $role->getName(); + + $roleName = $this->getDb()->quoteIdentifier($roleName); + + if ($cache) { + FilterProcessor::apply($roleFilter, $query); + $where = $query->getSelectBase()->getWhere(); + $query->getSelectBase()->resetWhere(); + + $values = []; + $rendered = $this->getDb()->getQueryBuilder()->buildCondition($where, $values); + $roleExpressions[$roleName] = new Expression($rendered, null, ...$values); + } else { + $subQuery = clone $query; + $roleExpressions[$roleName] = $subQuery + ->columns([new Expression('1')]) + ->filter($roleFilter) + ->filter($filter) + ->limit(1) + ->assembleSelect() + ->resetOrderBy(); + } + } + + $rolesWithRestrictions = []; + if (! empty($roleExpressions)) { + if ($cache) { + $query->columns('id')->withColumns($roleExpressions); + $query->filter($filter); + } else { + $query = [$this->getDb()->fetchOne((new Select())->columns($roleExpressions))]; + } + + foreach ($query as $row) { + $roles = $rolesWithoutRestrictions; + foreach ($inspectedRoles as $alias => $roleName) { + if ($row->$alias) { + $rolesWithRestrictions[$roleName] = true; + $roles[] = $roleName; + } + } + + if ($cache) { + self::$knownGrants[$tableName][$row->id] = $roles; + } + } + } + + self::$knownGrants[$tableName][$cacheKey] = array_merge( + $rolesWithoutRestrictions, + array_keys($rolesWithRestrictions) + ); + } + + /** + * Check if any of the given roles grants the permission + * + * @param string $permission + * @param array $roles + * + * @return bool + */ + protected function checkGrants(string $permission, array $roles): bool + { + if (empty($roles)) { + return false; + } + + $granted = false; + foreach ($this->getAuth()->getUser()->getRoles() as $role) { + if ($role->denies($permission)) { + return false; + } elseif ($granted || ! $role->grants($permission)) { + continue; + } + + $granted = in_array($role->getName(), $roles, true); + } + + return $granted; + } +} diff --git a/library/Icingadb/Command/IcingaApiCommand.php b/library/Icingadb/Command/IcingaApiCommand.php new file mode 100644 index 0000000..f3f0c33 --- /dev/null +++ b/library/Icingadb/Command/IcingaApiCommand.php @@ -0,0 +1,128 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command; + +class IcingaApiCommand +{ + /** + * Command data + * + * @var array + */ + protected $data; + + /** + * Name of the endpoint + * + * @var string + */ + protected $endpoint; + + /** + * HTTP method to use + * + * @var string + */ + protected $method = 'POST'; + + /** + * Create a new Icinga 2 API command + * + * @param string $endpoint + * @param array $data + * + * @return static + */ + public static function create(string $endpoint, array $data): self + { + return (new static()) + ->setEndpoint($endpoint) + ->setData($data); + } + + /** + * Get the command data + * + * @return array + */ + public function getData(): array + { + if ($this->data === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->data; + } + + /** + * Set the command data + * + * @param array $data + * + * @return $this + */ + public function setData(array $data): self + { + $this->data = $data; + + return $this; + } + + /** + * Get the name of the endpoint + * + * @return string + */ + public function getEndpoint(): string + { + if ($this->endpoint === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->endpoint; + } + + /** + * Set the name of the endpoint + * + * @param string $endpoint + * + * @return $this + */ + public function setEndpoint(string $endpoint): self + { + $this->endpoint = $endpoint; + + return $this; + } + + /** + * Get the HTTP method to use + * + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * Set the HTTP method to use + * + * @param string $method All uppercase HTTP method name. Case-sensitive. + * + * @return $this + */ + public function setMethod(string $method): self + { + $this->method = $method; + + return $this; + } +} diff --git a/library/Icingadb/Command/IcingaCommand.php b/library/Icingadb/Command/IcingaCommand.php new file mode 100644 index 0000000..7b5c5cf --- /dev/null +++ b/library/Icingadb/Command/IcingaCommand.php @@ -0,0 +1,22 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command; + +/** + * Base class for commands sent to an Icinga instance + */ +abstract class IcingaCommand +{ + /** + * Get the name of the command + * + * @return string + */ + public function getName(): string + { + $nsParts = explode('\\', get_called_class()); + return substr_replace(end($nsParts), '', -7); // Remove 'Command' Suffix + } +} diff --git a/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php b/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php new file mode 100644 index 0000000..d275d9b --- /dev/null +++ b/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php @@ -0,0 +1,109 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Instance; + +use Icinga\Module\Icingadb\Command\IcingaCommand; + +/** + * Enable or disable a feature of an Icinga instance + */ +class ToggleInstanceFeatureCommand extends IcingaCommand +{ + /** + * Feature for enabling or disabling active host checks on an Icinga instance + */ + const FEATURE_ACTIVE_HOST_CHECKS = 'active_host_checks_enabled'; + + /** + * Feature for enabling or disabling active service checks on an Icinga instance + */ + const FEATURE_ACTIVE_SERVICE_CHECKS = 'active_service_checks_enabled'; + + /** + * Feature for enabling or disabling host and service event handlers on an Icinga instance + */ + const FEATURE_EVENT_HANDLERS = 'event_handlers_enabled'; + + /** + * Feature for enabling or disabling host and service flap detection on an Icinga instance + */ + const FEATURE_FLAP_DETECTION = 'flap_detection_enabled'; + + /** + * Feature for enabling or disabling host and service notifications on an Icinga instance + */ + const FEATURE_NOTIFICATIONS = 'notifications_enabled'; + + /** + * Feature for enabling or disabling the processing of host and service performance data on an Icinga instance + */ + const FEATURE_PERFORMANCE_DATA = 'process_performance_data'; + + /** + * Feature that is to be enabled or disabled + * + * @var string + */ + protected $feature; + + /** + * Whether the feature should be enabled or disabled + * + * @var bool + */ + protected $enabled; + + /** + * Set the feature that is to be enabled or disabled + * + * @param string $feature + * + * @return $this + */ + public function setFeature(string $feature): self + { + $this->feature = $feature; + + return $this; + } + + /** + * Get the feature that is to be enabled or disabled + * + * @return string + */ + public function getFeature(): string + { + if ($this->feature === null) { + throw new \LogicException('You have to set the feature first before getting it.'); + } + + return $this->feature; + } + + /** + * Set whether the feature should be enabled or disabled + * + * @param bool $enabled + * + * @return $this + */ + public function setEnabled(bool $enabled = true): self + { + $this->enabled = $enabled; + + return $this; + } + + /** + * Get whether the feature should be enabled or disabled + * + * @return ?bool + */ + public function getEnabled() + { + return $this->enabled; + } +} diff --git a/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php b/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php new file mode 100644 index 0000000..baae24c --- /dev/null +++ b/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php @@ -0,0 +1,140 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Acknowledge a host or service problem + */ +class AcknowledgeProblemCommand extends WithCommentCommand +{ + /** + * Whether the acknowledgement is sticky + * + * Sticky acknowledgements remain until the host or service recovers. Non-sticky acknowledgements will be + * automatically removed when the host or service state changes. + * + * @var bool + */ + protected $sticky = false; + + /** + * Whether to send a notification about the acknowledgement + + * @var bool + */ + protected $notify = false; + + /** + * Whether the comment associated with the acknowledgement is persistent + * + * Persistent comments are not lost the next time the monitoring host restarts. + * + * @var bool + */ + protected $persistent = false; + + /** + * Optional time when the acknowledgement should expire + * + * @var int + */ + protected $expireTime; + + /** + * Set whether the acknowledgement is sticky + * + * @param bool $sticky + * + * @return $this + */ + public function setSticky(bool $sticky = true): self + { + $this->sticky = $sticky; + + return $this; + } + + /** + * Is the acknowledgement sticky? + * + * @return bool + */ + public function getSticky(): bool + { + return $this->sticky; + } + + /** + * Set whether to send a notification about the acknowledgement + * + * @param bool $notify + * + * @return $this + */ + public function setNotify(bool $notify = true): self + { + $this->notify = $notify; + + return $this; + } + + /** + * Get whether to send a notification about the acknowledgement + * + * @return bool + */ + public function getNotify(): bool + { + return $this->notify; + } + + /** + * Set whether the comment associated with the acknowledgement is persistent + * + * @param bool $persistent + * + * @return $this + */ + public function setPersistent(bool $persistent = true): self + { + $this->persistent = $persistent; + + return $this; + } + + /** + * Is the comment associated with the acknowledgement is persistent? + * + * @return bool + */ + public function getPersistent(): bool + { + return $this->persistent; + } + + /** + * Set the time when the acknowledgement should expire + * + * @param int $expireTime + * + * @return $this + */ + public function setExpireTime(int $expireTime): self + { + $this->expireTime = $expireTime; + + return $this; + } + + /** + * Get the time when the acknowledgement should expire + * + * @return ?int + */ + public function getExpireTime() + { + return $this->expireTime; + } +} diff --git a/library/Icingadb/Command/Object/AddCommentCommand.php b/library/Icingadb/Command/Object/AddCommentCommand.php new file mode 100644 index 0000000..c853b25 --- /dev/null +++ b/library/Icingadb/Command/Object/AddCommentCommand.php @@ -0,0 +1,42 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Add a comment to a host or service + */ +class AddCommentCommand extends WithCommentCommand +{ + /** + * Optional time when the acknowledgement should expire + * + * @var int + */ + protected $expireTime; + + /** + * Set the time when the acknowledgement should expire + * + * @param int $expireTime + * + * @return $this + */ + public function setExpireTime(int $expireTime): self + { + $this->expireTime = $expireTime; + + return $this; + } + + /** + * Get the time when the acknowledgement should expire + * + * @return ?int + */ + public function getExpireTime() + { + return $this->expireTime; + } +} diff --git a/library/Icingadb/Command/Object/CommandAuthor.php b/library/Icingadb/Command/Object/CommandAuthor.php new file mode 100644 index 0000000..f323b63 --- /dev/null +++ b/library/Icingadb/Command/Object/CommandAuthor.php @@ -0,0 +1,45 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +trait CommandAuthor +{ + /** + * Author of the command + * + * @var string + */ + protected $author; + + /** + * Set the author + * + * @param string $author + * + * @return $this + */ + public function setAuthor(string $author): self + { + $this->author = $author; + + return $this; + } + + /** + * Get the author + * + * @return string + */ + public function getAuthor(): string + { + if ($this->author === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->author; + } +} diff --git a/library/Icingadb/Command/Object/DeleteCommentCommand.php b/library/Icingadb/Command/Object/DeleteCommentCommand.php new file mode 100644 index 0000000..c06a73c --- /dev/null +++ b/library/Icingadb/Command/Object/DeleteCommentCommand.php @@ -0,0 +1,13 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Delete a host or service comment + */ +class DeleteCommentCommand extends ObjectsCommand +{ + use CommandAuthor; +} diff --git a/library/Icingadb/Command/Object/DeleteDowntimeCommand.php b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php new file mode 100644 index 0000000..7b4c282 --- /dev/null +++ b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php @@ -0,0 +1,13 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Delete a host or service downtime + */ +class DeleteDowntimeCommand extends ObjectsCommand +{ + use CommandAuthor; +} diff --git a/library/Icingadb/Command/Object/GetObjectCommand.php b/library/Icingadb/Command/Object/GetObjectCommand.php new file mode 100644 index 0000000..8448f8d --- /dev/null +++ b/library/Icingadb/Command/Object/GetObjectCommand.php @@ -0,0 +1,35 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +class GetObjectCommand extends ObjectsCommand +{ + /** @var array */ + protected $attributes; + + /** + * Get the attributes to query + * + * @return ?array + */ + public function getAttributes() + { + return $this->attributes; + } + + /** + * Set the attributes to query + * + * @param array $attributes + * + * @return $this + */ + public function setAttributes(array $attributes): self + { + $this->attributes = $attributes; + + return $this; + } +} diff --git a/library/Icingadb/Command/Object/ObjectsCommand.php b/library/Icingadb/Command/Object/ObjectsCommand.php new file mode 100644 index 0000000..3de6c83 --- /dev/null +++ b/library/Icingadb/Command/Object/ObjectsCommand.php @@ -0,0 +1,67 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +use ArrayIterator; +use Icinga\Module\Icingadb\Command\IcingaCommand; +use ipl\Orm\Model; +use Traversable; + +/** + * Base class for commands that involve monitored objects, i.e. hosts or services + */ +abstract class ObjectsCommand extends IcingaCommand +{ + /** + * Involved objects + * + * @var Traversable<Model> + */ + protected $objects; + + /** + * Set the involved objects + * + * @param Traversable<Model> $objects + * + * @return $this + */ + public function setObjects(Traversable $objects): self + { + $this->objects = $objects; + + return $this; + } + + /** + * Set the involved object + * + * @param Model $object + * + * @return $this + * + * @deprecated Use setObjects() instead + */ + public function setObject(Model $object): self + { + return $this->setObjects(new ArrayIterator([$object])); + } + + /** + * Get the involved objects + * + * @return Traversable + */ + public function getObjects(): Traversable + { + if ($this->objects === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->objects; + } +} diff --git a/library/Icingadb/Command/Object/ProcessCheckResultCommand.php b/library/Icingadb/Command/Object/ProcessCheckResultCommand.php new file mode 100644 index 0000000..24ae2f3 --- /dev/null +++ b/library/Icingadb/Command/Object/ProcessCheckResultCommand.php @@ -0,0 +1,140 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Submit a passive check result for a host or service + */ +class ProcessCheckResultCommand extends ObjectsCommand +{ + /** + * Host up + */ + const HOST_UP = 0; + + /** + * Host down + */ + const HOST_DOWN = 1; + + /** + * Service ok + */ + const SERVICE_OK = 0; + + /** + * Service warning + */ + const SERVICE_WARNING = 1; + + /** + * Service critical + */ + const SERVICE_CRITICAL = 2; + + /** + * Service unknown + */ + const SERVICE_UNKNOWN = 3; + + /** + * Status code of the host or service check result + * + * @var int + */ + protected $status; + + /** + * Text output of the host or service check result + * + * @var string + */ + protected $output; + + /** + * Optional performance data of the host or service check result + * + * @var string + */ + protected $performanceData; + + /** + * Set the status code of the host or service check result + * + * @param int $status + * + * @return $this + */ + public function setStatus(int $status): self + { + $this->status = $status; + + return $this; + } + + /** + * Get the status code of the host or service check result + * + * @return int + */ + public function getStatus(): int + { + if ($this->status === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->status; + } + + /** + * Set the text output of the host or service check result + * + * @param string $output + * + * @return $this + */ + public function setOutput(string $output): self + { + $this->output = $output; + + return $this; + } + + /** + * Get the text output of the host or service check result + * + * @return ?string + */ + public function getOutput() + { + return $this->output; + } + + /** + * Set the performance data of the host or service check result + * + * @param string|null $performanceData + * + * @return $this + */ + public function setPerformanceData(string $performanceData = null): self + { + $this->performanceData = $performanceData; + + return $this; + } + + /** + * Get the performance data of the host or service check result + * + * @return ?string + */ + public function getPerformanceData() + { + return $this->performanceData; + } +} diff --git a/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php b/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php new file mode 100644 index 0000000..88964fb --- /dev/null +++ b/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php @@ -0,0 +1,42 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Schedule and propagate host downtime + */ +class PropagateHostDowntimeCommand extends ScheduleHostDowntimeCommand +{ + /** + * Whether the downtime for child hosts are all set to be triggered by this' host downtime + * + * @var bool + */ + protected $triggered = false; + + /** + * Set whether the downtime for child hosts are all set to be triggered by this' host downtime + * + * @param bool $triggered + * + * @return $this + */ + public function setTriggered(bool $triggered = true): self + { + $this->triggered = $triggered; + + return $this; + } + + /** + * Get whether the downtime for child hosts are all set to be triggered by this' host downtime + * + * @return bool + */ + public function getTriggered(): bool + { + return $this->triggered; + } +} diff --git a/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php b/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php new file mode 100644 index 0000000..4c4a9b3 --- /dev/null +++ b/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php @@ -0,0 +1,13 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Remove a problem acknowledgement from a host or service + */ +class RemoveAcknowledgementCommand extends ObjectsCommand +{ + use CommandAuthor; +} diff --git a/library/Icingadb/Command/Object/ScheduleCheckCommand.php b/library/Icingadb/Command/Object/ScheduleCheckCommand.php new file mode 100644 index 0000000..88a7fd3 --- /dev/null +++ b/library/Icingadb/Command/Object/ScheduleCheckCommand.php @@ -0,0 +1,86 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Schedule a check + */ +class ScheduleCheckCommand extends ObjectsCommand +{ + /** + * Time when the next check of a host or service is to be scheduled + * + * If active checks are disabled on a host- or service-specific or program-wide basis or the host or service is + * already scheduled to be checked at an earlier time, etc. The check may not actually be scheduled at the time + * specified. This behaviour can be overridden by setting `ScheduledCheck::$forced' to true. + * + * @var int Unix timestamp + */ + protected $checkTime; + + /** + * Whether the check is forced + * + * Forced checks are performed regardless of what time it is (e.g. time period restrictions are ignored) and whether + * or not active checks are enabled on a host- or service-specific or program-wide basis. + * + * @var bool + */ + protected $forced = false; + + /** + * Set the time when the next check of a host or service is to be scheduled + * + * @param int $checkTime Unix timestamp + * + * @return $this + */ + public function setCheckTime(int $checkTime): self + { + $this->checkTime = $checkTime; + + return $this; + } + + /** + * Get the time when the next check of a host or service is to be scheduled + * + * @return int Unix timestamp + */ + public function getCheckTime(): int + { + if ($this->checkTime === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->checkTime; + } + + /** + * Set whether the check is forced + * + * @param bool $forced + * + * @return $this + */ + public function setForced(bool $forced = true): self + { + $this->forced = $forced; + + return $this; + } + + /** + * Get whether the check is forced + * + * @return bool + */ + public function getForced(): bool + { + return $this->forced; + } +} diff --git a/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php b/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php new file mode 100644 index 0000000..0e4d84f --- /dev/null +++ b/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php @@ -0,0 +1,42 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Schedule a host downtime + */ +class ScheduleHostDowntimeCommand extends ScheduleServiceDowntimeCommand +{ + /** + * Whether to schedule a downtime for all services associated with a particular host + * + * @var bool + */ + protected $forAllServices = false; + + /** + * Set whether to schedule a downtime for all services associated with a particular host + * + * @param bool $forAllServices + * + * @return $this + */ + public function setForAllServices(bool $forAllServices = true): self + { + $this->forAllServices = $forAllServices; + + return $this; + } + + /** + * Get whether to schedule a downtime for all services associated with a particular host + * + * @return bool + */ + public function getForAllServices(): bool + { + return $this->forAllServices; + } +} diff --git a/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php b/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php new file mode 100644 index 0000000..3bad28e --- /dev/null +++ b/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php @@ -0,0 +1,196 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Schedule a service downtime + */ +class ScheduleServiceDowntimeCommand extends AddCommentCommand +{ + /** + * Downtime starts at the exact time specified + * + * If `Downtime::$fixed' is set to false, the time between `Downtime::$start' and `Downtime::$end' at which a + * host or service transitions to a problem state determines the time at which the downtime actually starts. + * The downtime will then last for `Downtime::$duration' seconds. + * + * @var int Unix timestamp + */ + protected $start; + + /** + * Downtime ends at the exact time specified + * + * If `Downtime::$fixed' is set to false, the time between `Downtime::$start' and `Downtime::$end' at which a + * host or service transitions to a problem state determines the time at which the downtime actually starts. + * The downtime will then last for `Downtime::$duration' seconds. + * + * @var int Unix timestamp + */ + protected $end; + + /** + * Whether it's a fixed or flexible downtime + * + * @var bool + */ + protected $fixed = true; + + /** + * ID of the downtime which triggers this downtime + * + * The start of this downtime is triggered by the start of the other scheduled host or service downtime. + * + * @var int|null + */ + protected $triggerId; + + /** + * The duration in seconds the downtime must last if it's a flexible downtime + * + * If `Downtime::$fixed' is set to false, the downtime will last for the duration in seconds specified, even + * if the host or service recovers before the downtime expires. + * + * @var int|null + */ + protected $duration; + + /** + * Set the time when the downtime should start + * + * @param int $start Unix timestamp + * + * @return $this + */ + public function setStart(int $start): self + { + $this->start = $start; + + return $this; + } + + /** + * Get the time when the downtime should start + * + * @return int Unix timestamp + */ + public function getStart(): int + { + if ($this->start === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->start; + } + + /** + * Set the time when the downtime should end + * + * @param int $end Unix timestamp + * + * @return $this + */ + public function setEnd(int $end): self + { + $this->end = $end; + + return $this; + } + + /** + * Get the time when the downtime should end + * + * @return int Unix timestamp + */ + public function getEnd(): int + { + if ($this->start === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->end; + } + + /** + * Set whether it's a fixed or flexible downtime + * + * @param boolean $fixed + * + * @return $this + */ + public function setFixed(bool $fixed = true): self + { + $this->fixed = $fixed; + + return $this; + } + + /** + * Is the downtime fixed? + * + * @return boolean + */ + public function getFixed(): bool + { + return $this->fixed; + } + + /** + * Set the ID of the downtime which triggers this downtime + * + * @param int $triggerId + * + * @return $this + */ + public function setTriggerId(int $triggerId): self + { + $this->triggerId = $triggerId; + + return $this; + } + + /** + * Get the ID of the downtime which triggers this downtime + * + * @return int|null + */ + public function getTriggerId() + { + return $this->triggerId; + } + + /** + * Set the duration in seconds the downtime must last if it's a flexible downtime + * + * @param int $duration + * + * @return $this + */ + public function setDuration(int $duration): self + { + $this->duration = $duration; + + return $this; + } + + /** + * Get the duration in seconds the downtime must last if it's a flexible downtime + * + * @return int|null + */ + public function getDuration() + { + return $this->duration; + } + + public function getName(): string + { + return 'ScheduleDowntime'; + } +} diff --git a/library/Icingadb/Command/Object/SendCustomNotificationCommand.php b/library/Icingadb/Command/Object/SendCustomNotificationCommand.php new file mode 100644 index 0000000..de90620 --- /dev/null +++ b/library/Icingadb/Command/Object/SendCustomNotificationCommand.php @@ -0,0 +1,44 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Send custom notifications for a host or service + */ +class SendCustomNotificationCommand extends WithCommentCommand +{ + /** + * Whether the notification is forced + * + * Forced notifications are sent out regardless of time restrictions and whether or not notifications are enabled. + * + * @var bool + */ + protected $forced; + + /** + * Get whether to force the notification + * + * @return ?bool + */ + public function getForced() + { + return $this->forced; + } + + /** + * Set whether to force the notification + * + * @param bool $forced + * + * @return $this + */ + public function setForced(bool $forced = true): self + { + $this->forced = $forced; + + return $this; + } +} diff --git a/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php b/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php new file mode 100644 index 0000000..ec66289 --- /dev/null +++ b/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php @@ -0,0 +1,108 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Enable or disable a feature of an Icinga object, i.e. host or service + */ +class ToggleObjectFeatureCommand extends ObjectsCommand +{ + /** + * Feature for enabling or disabling active checks of a host or service + */ + const FEATURE_ACTIVE_CHECKS = 'active_checks_enabled'; + + /** + * Feature for enabling or disabling passive checks of a host or service + */ + const FEATURE_PASSIVE_CHECKS = 'passive_checks_enabled'; + + /** + * Feature for enabling or disabling notifications for a host or service + * + * Notifications will be sent out only if notifications are enabled on a program-wide basis as well. + */ + const FEATURE_NOTIFICATIONS = 'notifications_enabled'; + + /** + * Feature for enabling or disabling event handler for a host or service + */ + const FEATURE_EVENT_HANDLER = 'event_handler_enabled'; + + /** + * Feature for enabling or disabling flap detection for a host or service. + * + * In order to enable flap detection flap detection must be enabled on a program-wide basis as well. + */ + const FEATURE_FLAP_DETECTION = 'flapping_enabled'; + + /** + * Feature that is to be enabled or disabled + * + * @var string + */ + protected $feature; + + /** + * Whether the feature should be enabled or disabled + * + * @var bool + */ + protected $enabled; + + /** + * Set the feature that is to be enabled or disabled + * + * @param string $feature + * + * @return $this + */ + public function setFeature(string $feature): self + { + $this->feature = $feature; + + return $this; + } + + /** + * Get the feature that is to be enabled or disabled + * + * @return string + */ + public function getFeature(): string + { + if ($this->feature === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->feature; + } + + /** + * Set whether the feature should be enabled or disabled + * + * @param bool $enabled + * + * @return $this + */ + public function setEnabled(bool $enabled = true): self + { + $this->enabled = $enabled; + + return $this; + } + + /** + * Get whether the feature should be enabled or disabled + * + * @return ?bool + */ + public function getEnabled() + { + return $this->enabled; + } +} diff --git a/library/Icingadb/Command/Object/WithCommentCommand.php b/library/Icingadb/Command/Object/WithCommentCommand.php new file mode 100644 index 0000000..fc92eb1 --- /dev/null +++ b/library/Icingadb/Command/Object/WithCommentCommand.php @@ -0,0 +1,50 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Object; + +/** + * Base class for commands adding comments + */ +abstract class WithCommentCommand extends ObjectsCommand +{ + use CommandAuthor; + + /** + * Comment + * + * @var string + */ + protected $comment; + + /** + * Set the comment + * + * @param string $comment + * + * @return $this + */ + public function setComment(string $comment): self + { + $this->comment = $comment; + + return $this; + } + + /** + * Get the comment + * + * @return string + */ + public function getComment(): string + { + if ($this->comment === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->comment; + } +} diff --git a/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php b/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php new file mode 100644 index 0000000..37075c9 --- /dev/null +++ b/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php @@ -0,0 +1,353 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Renderer; + +use Icinga\Module\Icingadb\Command\IcingaApiCommand; +use Icinga\Module\Icingadb\Command\Object\GetObjectCommand; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Module\Icingadb\Command\Instance\ToggleInstanceFeatureCommand; +use Icinga\Module\Icingadb\Command\Object\AcknowledgeProblemCommand; +use Icinga\Module\Icingadb\Command\Object\AddCommentCommand; +use Icinga\Module\Icingadb\Command\Object\DeleteCommentCommand; +use Icinga\Module\Icingadb\Command\Object\DeleteDowntimeCommand; +use Icinga\Module\Icingadb\Command\Object\ProcessCheckResultCommand; +use Icinga\Module\Icingadb\Command\Object\PropagateHostDowntimeCommand; +use Icinga\Module\Icingadb\Command\Object\RemoveAcknowledgementCommand; +use Icinga\Module\Icingadb\Command\Object\ScheduleHostDowntimeCommand; +use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand; +use Icinga\Module\Icingadb\Command\Object\ScheduleServiceDowntimeCommand; +use Icinga\Module\Icingadb\Command\Object\SendCustomNotificationCommand; +use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand; +use Icinga\Module\Icingadb\Command\IcingaCommand; +use InvalidArgumentException; +use ipl\Orm\Model; +use LogicException; +use Traversable; + +/** + * Icinga command renderer for the Icinga command file + */ +class IcingaApiCommandRenderer implements IcingaCommandRendererInterface +{ + /** + * Name of the Icinga application object + * + * @var string + */ + protected $app = 'app'; + + /** + * Get the name of the Icinga application object + * + * @return string + */ + public function getApp(): string + { + return $this->app; + } + + /** + * Set the name of the Icinga application object + * + * @param string $app + * + * @return $this + */ + public function setApp(string $app): self + { + $this->app = $app; + + return $this; + } + + /** + * Apply filter to query data + * + * @param array $data + * @param Traversable<Model> $objects + * + * @return ?Model Any of the objects (useful for further type-dependent handling) + */ + protected function applyFilter(array &$data, Traversable $objects): ?Model + { + $object = null; + + foreach ($objects as $object) { + if ($object instanceof Service) { + $data['services'][] = sprintf('%s!%s', $object->host->name, $object->name); + } else { + $data['hosts'][] = $object->name; + } + } + + return $object; + } + + /** + * Get the sub-route of the endpoint for an object + * + * @param Model $object + * + * @return string + */ + protected function getObjectPluralType(Model $object): string + { + if ($object instanceof Host) { + return 'hosts'; + } + + if ($object instanceof Service) { + return 'services'; + } + + throw new LogicException(sprintf('Invalid object type %s provided', get_class($object))); + } + + /** + * Render a command + * + * @param IcingaCommand $command + * + * @return IcingaApiCommand + */ + public function render(IcingaCommand $command): IcingaApiCommand + { + $renderMethod = 'render' . $command->getName(); + if (! method_exists($this, $renderMethod)) { + throw new InvalidArgumentException( + sprintf('Can\'t render command. Method %s not found', $renderMethod) + ); + } + + return $this->$renderMethod($command); + } + + public function renderGetObject(GetObjectCommand $command): IcingaApiCommand + { + $data = [ + 'all_joins' => 1, + 'attrs' => $command->getAttributes() ?: [] + ]; + + $endpoint = 'objects/' . $this->getObjectPluralType($this->applyFilter($data, $command->getObjects())); + + return IcingaApiCommand::create($endpoint, $data)->setMethod('GET'); + } + + public function renderAddComment(AddCommentCommand $command): IcingaApiCommand + { + $endpoint = 'actions/add-comment'; + $data = [ + 'author' => $command->getAuthor(), + 'comment' => $command->getComment() + ]; + + if ($command->getExpireTime() !== null) { + $data['expiry'] = $command->getExpireTime(); + } + + $this->applyFilter($data, $command->getObjects()); + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderSendCustomNotification(SendCustomNotificationCommand $command): IcingaApiCommand + { + $endpoint = 'actions/send-custom-notification'; + $data = [ + 'author' => $command->getAuthor(), + 'comment' => $command->getComment(), + 'force' => $command->getForced() + ]; + + $this->applyFilter($data, $command->getObjects()); + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderProcessCheckResult(ProcessCheckResultCommand $command): IcingaApiCommand + { + $endpoint = 'actions/process-check-result'; + $data = [ + 'exit_status' => $command->getStatus(), + 'plugin_output' => $command->getOutput(), + 'performance_data' => $command->getPerformanceData() + ]; + + $this->applyFilter($data, $command->getObjects()); + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderScheduleCheck(ScheduleCheckCommand $command): IcingaApiCommand + { + $endpoint = 'actions/reschedule-check'; + $data = [ + 'next_check' => $command->getCheckTime(), + 'force' => $command->getForced() + ]; + + $this->applyFilter($data, $command->getObjects()); + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderScheduleDowntime(ScheduleServiceDowntimeCommand $command): IcingaApiCommand + { + $endpoint = 'actions/schedule-downtime'; + $data = [ + 'author' => $command->getAuthor(), + 'comment' => $command->getComment(), + 'start_time' => $command->getStart(), + 'end_time' => $command->getEnd(), + 'duration' => $command->getDuration(), + 'fixed' => $command->getFixed(), + 'trigger_name' => $command->getTriggerId() + ]; + + if ($command instanceof PropagateHostDowntimeCommand) { + $data['child_options'] = $command->getTriggered() ? 1 : 2; + } + + if ($command instanceof ScheduleHostDowntimeCommand && $command->getForAllServices()) { + $data['all_services'] = true; + } + + $this->applyFilter($data, $command->getObjects()); + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderAcknowledgeProblem(AcknowledgeProblemCommand $command): IcingaApiCommand + { + $endpoint = 'actions/acknowledge-problem'; + $data = [ + 'author' => $command->getAuthor(), + 'comment' => $command->getComment(), + 'sticky' => $command->getSticky(), + 'notify' => $command->getNotify(), + 'persistent' => $command->getPersistent() + ]; + + if ($command->getExpireTime() !== null) { + $data['expiry'] = $command->getExpireTime(); + } + + $this->applyFilter($data, $command->getObjects()); + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderToggleObjectFeature(ToggleObjectFeatureCommand $command): IcingaApiCommand + { + switch ($command->getFeature()) { + case ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS: + $attr = 'enable_active_checks'; + break; + case ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS: + $attr = 'enable_passive_checks'; + break; + case ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS: + $attr = 'enable_notifications'; + break; + case ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER: + $attr = 'enable_event_handler'; + break; + case ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION: + $attr = 'enable_flapping'; + break; + default: + throw new InvalidArgumentException($command->getFeature()); + } + + $endpoint = 'objects/'; + $objects = $command->getObjects(); + + $data = [ + 'attrs' => [ + $attr => $command->getEnabled() + ] + ]; + + + $endpoint .= $this->getObjectPluralType($this->applyFilter($data, $objects)); + + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderDeleteComment(DeleteCommentCommand $command): IcingaApiCommand + { + $comments = []; + + foreach ($command->getObjects() as $object) { + $comments[] = $object->name; + } + + $endpoint = 'actions/remove-comment'; + $data = [ + 'author' => $command->getAuthor(), + 'comments' => $comments + ]; + + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderDeleteDowntime(DeleteDowntimeCommand $command): IcingaApiCommand + { + $downtimes = []; + + foreach ($command->getObjects() as $object) { + $downtimes[] = $object->name; + } + + $endpoint = 'actions/remove-downtime'; + $data = [ + 'author' => $command->getAuthor(), + 'downtimes' => $downtimes + ]; + + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderRemoveAcknowledgement(RemoveAcknowledgementCommand $command): IcingaApiCommand + { + $endpoint = 'actions/remove-acknowledgement'; + $data = ['author' => $command->getAuthor()]; + + $this->applyFilter($data, $command->getObjects()); + return IcingaApiCommand::create($endpoint, $data); + } + + public function renderToggleInstanceFeature(ToggleInstanceFeatureCommand $command): IcingaApiCommand + { + $endpoint = 'objects/icingaapplications/' . $this->getApp(); + + switch ($command->getFeature()) { + case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS: + $attr = 'enable_host_checks'; + break; + case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS: + $attr = 'enable_service_checks'; + break; + case ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS: + $attr = 'enable_event_handlers'; + break; + case ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION: + $attr = 'enable_flapping'; + break; + case ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS: + $attr = 'enable_notifications'; + break; + case ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA: + $attr = 'enable_perfdata'; + break; + default: + throw new InvalidArgumentException($command->getFeature()); + } + + $data = [ + 'attrs' => [ + $attr => $command->getEnabled() + ] + ]; + + return IcingaApiCommand::create($endpoint, $data); + } +} diff --git a/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php b/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php new file mode 100644 index 0000000..50dd90c --- /dev/null +++ b/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php @@ -0,0 +1,12 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Renderer; + +/** + * Interface for Icinga command renderer + */ +interface IcingaCommandRendererInterface +{ +} diff --git a/library/Icingadb/Command/Transport/ApiCommandException.php b/library/Icingadb/Command/Transport/ApiCommandException.php new file mode 100644 index 0000000..5449a7d --- /dev/null +++ b/library/Icingadb/Command/Transport/ApiCommandException.php @@ -0,0 +1,14 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Transport; + +use Icinga\Exception\IcingaException; + +/** + * Exception thrown if a command was not successful + */ +class ApiCommandException extends IcingaException +{ +} diff --git a/library/Icingadb/Command/Transport/ApiCommandTransport.php b/library/Icingadb/Command/Transport/ApiCommandTransport.php new file mode 100644 index 0000000..370d705 --- /dev/null +++ b/library/Icingadb/Command/Transport/ApiCommandTransport.php @@ -0,0 +1,353 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Transport; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use Icinga\Application\Hook\AuditHook; +use Icinga\Application\Logger; +use Icinga\Exception\Json\JsonDecodeException; +use Icinga\Module\Icingadb\Command\IcingaApiCommand; +use Icinga\Module\Icingadb\Command\IcingaCommand; +use Icinga\Module\Icingadb\Command\Renderer\IcingaApiCommandRenderer; +use Icinga\Util\Json; + +/** + * Command transport over Icinga 2's REST API + */ +class ApiCommandTransport implements CommandTransportInterface +{ + /** + * Transport identifier + */ + const TRANSPORT = 'api'; + + /** + * API host + * + * @var string + */ + protected $host; + + /** + * API password + * + * @var string + */ + protected $password; + + /** + * API port + * + * @var int + */ + protected $port = 5665; + + /** + * Command renderer + * + * @var IcingaApiCommandRenderer + */ + protected $renderer; + + /** + * API username + * + * @var string + */ + protected $username; + + /** + * Create a new API command transport + */ + public function __construct() + { + $this->renderer = new IcingaApiCommandRenderer(); + } + + /** + * Set the name of the Icinga application object + * + * @param string $app + * + * @return $this + */ + public function setApp(string $app): self + { + $this->renderer->setApp($app); + + return $this; + } + + /** + * Get the API host + * + * @return string + */ + public function getHost(): string + { + if ($this->host === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->host; + } + + /** + * Set the API host + * + * @param string $host + * + * @return $this + */ + public function setHost(string $host): self + { + $this->host = $host; + + return $this; + } + + /** + * Get the API password + * + * @return string + */ + public function getPassword(): string + { + if ($this->password === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->password; + } + + /** + * Set the API password + * + * @param string $password + * + * @return $this + */ + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * Get the API port + * + * @return int + */ + public function getPort(): int + { + return $this->port; + } + + /** + * Set the API port + * + * @param int $port + * + * @return $this + */ + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + /** + * Get the API username + * + * @return string + */ + public function getUsername(): string + { + if ($this->username === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->username; + } + + /** + * Set the API username + * + * @param string $username + * + * @return $this + */ + public function setUsername(string $username): self + { + $this->username = $username; + + return $this; + } + + /** + * Get URI for endpoint + * + * @param string $endpoint + * + * @return string + */ + protected function getUriFor(string $endpoint): string + { + return sprintf('https://%s:%u/v1/%s', $this->getHost(), $this->getPort(), $endpoint); + } + + /** + * Send the given command to the icinga2's REST API + * + * @param IcingaApiCommand $command + * + * @return mixed + */ + protected function sendCommand(IcingaApiCommand $command) + { + Logger::debug( + 'Sending Icinga command "%s" to the API "%s:%u"', + $command->getEndpoint(), + $this->getHost(), + $this->getPort() + ); + + $data = $command->getData(); + $payload = Json::encode($data); + AuditHook::logActivity( + 'monitoring/command', + "Issued command {$command->getEndpoint()} with the following payload: $payload", + $data + ); + + $headers = ['Accept' => 'application/json']; + if ($command->getMethod() !== 'POST') { + $headers['X-HTTP-Method-Override'] = $command->getMethod(); + } + + try { + $response = (new Client()) + ->post($this->getUriFor($command->getEndpoint()), [ + 'auth' => [$this->getUsername(), $this->getPassword()], + 'headers' => $headers, + 'json' => $command->getData(), + 'http_errors' => false, + 'verify' => false + ]); + } catch (GuzzleException $e) { + throw new CommandTransportException( + 'Can\'t connect to the Icinga 2 API: %u %s', + $e->getCode(), + $e->getMessage() + ); + } + + try { + $responseData = Json::decode((string) $response->getBody(), true); + } catch (JsonDecodeException $e) { + throw new CommandTransportException( + 'Got invalid JSON response from the Icinga 2 API: %s', + $e->getMessage() + ); + } + + if (! isset($responseData['results']) || empty($responseData['results'])) { + if (isset($responseData['error'])) { + throw new ApiCommandException( + 'Can\'t send external Icinga command: %u %s', + $responseData['error'], + $responseData['status'] + ); + } + + return; + } + + $errorResult = $responseData['results'][0]; + if (isset($errorResult['code']) && ($errorResult['code'] < 200 || $errorResult['code'] >= 300)) { + throw new ApiCommandException( + 'Can\'t send external Icinga command: %u %s', + $errorResult['code'], + $errorResult['status'] + ); + } + + return $responseData['results']; + } + + /** + * Send the Icinga command over the Icinga 2 API + * + * @param IcingaCommand $command + * @param int|null $now + * + * @throws CommandTransportException + * + * @return mixed + */ + public function send(IcingaCommand $command, int $now = null) + { + return $this->sendCommand($this->renderer->render($command)); + } + + /** + * Try to connect to the API + * + * @return void + * + * @throws CommandTransportException In case the connection was not successful + */ + public function probe() + { + try { + $response = (new Client(['timeout' => 15])) + ->get($this->getUriFor(''), [ + 'auth' => [$this->getUsername(), $this->getPassword()], + 'headers' => ['Accept' => 'application/json'], + 'http_errors' => false, + 'verify' => false + ]); + } catch (GuzzleException $e) { + throw new CommandTransportException( + 'Can\'t connect to the Icinga 2 API: %u %s', + $e->getCode(), + $e->getMessage() + ); + } + + try { + $responseData = Json::decode((string) $response->getBody(), true); + } catch (JsonDecodeException $e) { + throw new CommandTransportException( + 'Got invalid JSON response from the Icinga 2 API: %s', + $e->getMessage() + ); + } + + if (! isset($responseData['results']) || empty($responseData['results'])) { + throw new CommandTransportException( + 'Got invalid response from the Icinga 2 API: %s', + JSON::encode($responseData) + ); + } + + $result = array_pop($responseData['results']); + if (! isset($result['user']) || $result['user'] !== $this->getUsername()) { + throw new CommandTransportException( + 'Got invalid response from the Icinga 2 API: %s', + JSON::encode($responseData) + ); + } + } +} diff --git a/library/Icingadb/Command/Transport/CommandTransport.php b/library/Icingadb/Command/Transport/CommandTransport.php new file mode 100644 index 0000000..ea125bc --- /dev/null +++ b/library/Icingadb/Command/Transport/CommandTransport.php @@ -0,0 +1,130 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Transport; + +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Icingadb\Command\IcingaCommand; + +/** + * Command transport + */ +class CommandTransport implements CommandTransportInterface +{ + /** + * Transport configuration + * + * @var Config + */ + protected static $config; + + /** + * Get transport configuration + * + * @return Config + * + * @throws ConfigurationError + */ + public static function getConfig(): Config + { + if (static::$config === null) { + $config = Config::module('icingadb', 'commandtransports'); + if ($config->isEmpty()) { + throw new ConfigurationError( + t('No command transports have been configured in "%s".'), + $config->getConfigFile() + ); + } + + static::$config = $config; + } + + return static::$config; + } + + /** + * Create a transport from config + * + * @param ConfigObject $config + * + * @return ApiCommandTransport + * + * @throws ConfigurationError + */ + public static function createTransport(ConfigObject $config): ApiCommandTransport + { + $config = clone $config; + switch (strtolower($config->transport)) { + case ApiCommandTransport::TRANSPORT: + $transport = new ApiCommandTransport(); + break; + default: + throw new ConfigurationError( + t('Cannot create command transport "%s". Invalid transport defined in "%s". Use one of: %s.'), + $config->transport, + static::getConfig()->getConfigFile(), + join(', ', [ApiCommandTransport::TRANSPORT]) + ); + } + + unset($config->transport); + foreach ($config as $key => $value) { + $method = 'set' . ucfirst($key); + if (! method_exists($transport, $method)) { + // Ignore settings from config that don't have a setter on the transport instead of throwing an + // exception here because the transport should throw an exception if it's not fully set up + // when being about to send a command + continue; + } + + $transport->$method($value); + } + + return $transport; + } + + /** + * Send the given command over an appropriate Icinga command transport + * + * This will try one configured transport after another until the command has been successfully sent. + * + * @param IcingaCommand $command The command to send + * @param int|null $now Timestamp of the command or null for now + * + * @throws CommandTransportException If sending the Icinga command failed + * + * @return mixed + */ + public function send(IcingaCommand $command, int $now = null) + { + $errors = []; + + foreach (static::getConfig() as $name => $transportConfig) { + $transport = static::createTransport($transportConfig); + + try { + $result = $transport->send($command, $now); + } catch (CommandTransportException $e) { + Logger::error($e); + $errors[] = sprintf('%s: %s.', $name, rtrim($e->getMessage(), '.')); + continue; // Try the next transport + } + + return $result; // The command was successfully sent + } + + if (! empty($errors)) { + throw new CommandTransportException(implode("\n", $errors)); + } + + throw new CommandTransportException(t( + 'Failed to send external Icinga command. No transport has been configured' + . ' for this instance. Please contact your Icinga Web administrator.' + )); + } +} diff --git a/library/Icingadb/Command/Transport/CommandTransportConfig.php b/library/Icingadb/Command/Transport/CommandTransportConfig.php new file mode 100644 index 0000000..e17fa04 --- /dev/null +++ b/library/Icingadb/Command/Transport/CommandTransportConfig.php @@ -0,0 +1,31 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Transport; + +use Icinga\Repository\IniRepository; + +class CommandTransportConfig extends IniRepository +{ + protected $configs = [ + 'transport' => [ + 'name' => 'commandtransports', + 'module' => 'icingadb', + 'keyColumn' => 'name' + ] + ]; + + protected $queryColumns = [ + 'transport' => [ + 'name', + 'transport', + + // API options + 'host', + 'port', + 'username', + 'password' + ] + ]; +} diff --git a/library/Icingadb/Command/Transport/CommandTransportException.php b/library/Icingadb/Command/Transport/CommandTransportException.php new file mode 100644 index 0000000..2ca89d9 --- /dev/null +++ b/library/Icingadb/Command/Transport/CommandTransportException.php @@ -0,0 +1,14 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Transport; + +use Icinga\Exception\IcingaException; + +/** + * Exception thrown if a command was not sent + */ +class CommandTransportException extends IcingaException +{ +} diff --git a/library/Icingadb/Command/Transport/CommandTransportInterface.php b/library/Icingadb/Command/Transport/CommandTransportInterface.php new file mode 100644 index 0000000..ad07cb9 --- /dev/null +++ b/library/Icingadb/Command/Transport/CommandTransportInterface.php @@ -0,0 +1,23 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Command\Transport; + +use Icinga\Module\Icingadb\Command\IcingaCommand; + +/** + * Interface for Icinga command transports + */ +interface CommandTransportInterface +{ + /** + * Send an Icinga command over the Icinga command transport + * + * @param IcingaCommand $command The command to send + * @param int|null $now Timestamp of the command or null for now + * + * @throws CommandTransportException If sending the Icinga command failed + */ + public function send(IcingaCommand $command, int $now = null); +} diff --git a/library/Icingadb/Common/Auth.php b/library/Icingadb/Common/Auth.php new file mode 100644 index 0000000..d25526e --- /dev/null +++ b/library/Icingadb/Common/Auth.php @@ -0,0 +1,389 @@ +<?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; + + $hostStateRelation = array_search('host_state', $relations, true); + $serviceStateRelation = array_search('service_state', $relations, true); + + $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 (! $this->getAuth()->hasPermission('icingadb/object/show-source')) { + // In case the user does not have permission to see the object's `Source` tab, then the user must be + // restricted from accessing the executed command for the object. + $columns = $query->getColumns(); + $commandColumns = []; + if ($hostStateRelation !== false) { + $commandColumns[] = $resolver->qualifyColumn('check_commandline', $hostStateRelation); + } + + if ($serviceStateRelation !== false) { + $commandColumns[] = $resolver->qualifyColumn('check_commandline', $serviceStateRelation); + } + + if (! empty($columns)) { + foreach ($commandColumns as $commandColumn) { + $commandColumnPath = array_search($commandColumn, $columns, true); + if ($commandColumnPath !== false) { + $columns[$commandColumn] = new Expression("'***'"); + unset($columns[$commandColumnPath]); + } + } + + $query->columns($columns); + } else { + $query->withoutColumns($commandColumns); + } + } + + 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..5b1791f --- /dev/null +++ b/library/Icingadb/Common/BaseFilter.php @@ -0,0 +1,13 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +/** + * @deprecated Use {@see \ipl\Stdlib\BaseFilter} instead. This will be removed with version 1.1 + */ +trait BaseFilter +{ + use \ipl\Stdlib\BaseFilter; +} diff --git a/library/Icingadb/Common/BaseStatusBar.php b/library/Icingadb/Common/BaseStatusBar.php new file mode 100644 index 0000000..add176d --- /dev/null +++ b/library/Icingadb/Common/BaseStatusBar.php @@ -0,0 +1,55 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +use Icinga\Module\Icingadb\Model\HoststateSummary; +use Icinga\Module\Icingadb\Model\ServicestateSummary; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Orm\Model; +use ipl\Stdlib\BaseFilter; + +abstract class BaseStatusBar extends BaseHtmlElement +{ + use BaseFilter; + + /** @var ServicestateSummary|HoststateSummary */ + protected $summary; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'status-bar']; + + /** + * Create a host or service status bar + * + * @param ServicestateSummary|HoststateSummary $summary + */ + public function __construct($summary) + { + $this->summary = $summary; + } + + abstract protected function assembleTotal(BaseHtmlElement $total): void; + + abstract protected function createStateBadges(): BaseHtmlElement; + + protected function createCount(): BaseHtmlElement + { + $total = Html::tag('span', ['class' => 'item-count']); + + $this->assembleTotal($total); + + return $total; + } + + protected function assemble(): void + { + $this->add([ + $this->createCount(), + $this->createStateBadges() + ]); + } +} diff --git a/library/Icingadb/Common/CaptionDisabled.php b/library/Icingadb/Common/CaptionDisabled.php new file mode 100644 index 0000000..2cee178 --- /dev/null +++ b/library/Icingadb/Common/CaptionDisabled.php @@ -0,0 +1,31 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +trait CaptionDisabled +{ + /** @var bool */ + 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..2cd13fe --- /dev/null +++ b/library/Icingadb/Common/CommandActions.php @@ -0,0 +1,308 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +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 Icinga\Web\Notification; +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) + { + $isXhr = $this->getRequest()->isXmlHttpRequest(); + if ($isXhr && $this->getRequest()->isApiRequest()) { + // Prevents the framework already, this is just a fail-safe + $this->httpBadRequest('Responding with JSON during a Web request is not supported'); + } + + if (is_string($form)) { + /** @var CommandForm $form */ + $form = new $form(); + } + + $form->setObjects($this->getCommandTargets()); + + if ($isXhr) { + $this->handleWebRequest($form); + } else { + $this->handleApiRequest($form); + } + } + + /** + * Handle a Web request for the given form + * + * @param CommandForm $form + * + * @return void + */ + protected function handleWebRequest(CommandForm $form): void + { + $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->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($this->getServerRequest()); + + $this->addContent($form); + } + + /** + * Handle an API request for the given form + * + * @param CommandForm $form + * + * @return never + */ + protected function handleApiRequest(CommandForm $form) + { + $form->setIsApiTarget(); + $form->on($form::ON_SUCCESS, function () { + $this->getResponse() + ->json() + ->setSuccessData(Notification::getInstance()->popMessages()) + ->sendResponse(); + }); + + $form->handleRequest($this->getServerRequest()); + + $errors = []; + foreach ($form->getElements() as $element) { + $errors[$element->getName()] = $element->getMessages(); + } + + $response = $this->getResponse()->json(); + $response->setHttpResponseCode(422); + $response->setFailData($errors) + ->sendResponse(); + } + + 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(); + $form = null; + 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..b182b1f --- /dev/null +++ b/library/Icingadb/Common/DetailActions.php @@ -0,0 +1,145 @@ +<?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'); + }) + ->registerAttributeCallback('data-icinga-multiselect-hint-label', function () { + return $this->getDetailActionsDisabled() + ? null + : t('Use shift/cmd + click/arrow keys to select multiple items'); + }); + + return $this; + } + + /** + * Set the url to use for multiple selected list items + * + * @param Url $url + * + * @return $this + */ + protected function setMultiselectUrl(Url $url): self + { + $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 + { + $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() + ->registerAttributeCallback('data-action-item', function () { + return ! $this->getDetailActionsDisabled(); + }) + ->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..06b9236 --- /dev/null +++ b/library/Icingadb/Common/HostStates.php @@ -0,0 +1,107 @@ +<?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 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 '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::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::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..a22a0f0 --- /dev/null +++ b/library/Icingadb/Common/IcingaRedis.php @@ -0,0 +1,323 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +use Exception; +use Generator; +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 whether Redis is unavailable + * + * @return bool + */ + public static function isUnavailable(): bool + { + $self = self::instance(); + + if (! $self->redisUnavailable && $self->redis === null) { + try { + $self->getConnection(); + } catch (Exception $_) { + // getConnection already logs the error + } + } + + return $self->redisUnavailable; + } + + /** + * 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; + } + + /** + * Fetch host states + * + * @param array $ids The host ids to fetch results for + * @param array $columns The columns to include in the results + * + * @return Generator + */ + public static function fetchHostState(array $ids, array $columns): Generator + { + return self::fetchState('icinga:host:state', $ids, $columns); + } + + /** + * Fetch service states + * + * @param array $ids The service ids to fetch results for + * @param array $columns The columns to include in the results + * + * @return Generator + */ + public static function fetchServiceState(array $ids, array $columns): Generator + { + return self::fetchState('icinga:service:state', $ids, $columns); + } + + /** + * Fetch object states + * + * @param string $key The object key to access + * @param array $ids The object ids to fetch results for + * @param array $columns The columns to include in the results + * + * @return Generator + */ + protected static function fetchState(string $key, array $ids, array $columns): Generator + { + try { + $results = self::instance()->getConnection()->hmget($key, $ids); + } catch (Exception $_) { + // The error has already been logged elsewhere + return; + } + + foreach ($results as $i => $json) { + if ($json !== null) { + $data = json_decode($json, true); + $keyMap = array_fill_keys($columns, null); + unset($keyMap['is_overdue']); // Is calculated by Icinga DB, not Icinga 2, hence it's never in redis + + // TODO: Remove once https://github.com/Icinga/icinga2/issues/9427 is fixed + $data['state_type'] = $data['state_type'] === 0 ? 'soft' : 'hard'; + + if (isset($data['in_downtime']) && is_bool($data['in_downtime'])) { + $data['in_downtime'] = $data['in_downtime'] ? 'y' : 'n'; + } + + if (isset($data['is_acknowledged']) && is_int($data['is_acknowledged'])) { + $data['is_acknowledged'] = $data['is_acknowledged'] ? 'y' : 'n'; + } + + yield $ids[$i] => array_intersect_key(array_merge($keyMap, $data), $keyMap); + } + } + } + + /** + * Get the last icinga heartbeat from redis + * + * @param Redis|null $redis + * + * @return float|int|null + */ + 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 + ] + self::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('icingadb'); + } + + 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 + ] + self::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..5a11be3 --- /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): void + { + $header->addHtml($this->createTitle()); + $header->add($this->createTimestamp()); + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->addHtml($this->createHeader()); + if (!$this->isCaptionDisabled()) { + $main->addHtml($this->createCaption()); + } + } +} diff --git a/library/Icingadb/Common/ListItemDetailedLayout.php b/library/Icingadb/Common/ListItemDetailedLayout.php new file mode 100644 index 0000000..23aa017 --- /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): void + { + $header->add($this->createTitle()); + $header->add($this->createTimestamp()); + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $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..3cdf3a9 --- /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): void + { + $header->add($this->createTitle()); + if (! $this->isCaptionDisabled()) { + $header->add($this->createCaption()); + } + $header->add($this->createTimestamp()); + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->add($this->createHeader()); + } +} diff --git a/library/Icingadb/Common/LoadMore.php b/library/Icingadb/Common/LoadMore.php new file mode 100644 index 0000000..ad44e59 --- /dev/null +++ b/library/Icingadb/Common/LoadMore.php @@ -0,0 +1,111 @@ +<?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')) + ->setAttributes([ + 'class' => 'load-more', + '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..4842c27 --- /dev/null +++ b/library/Icingadb/Common/Macros.php @@ -0,0 +1,120 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +use Icinga\Application\Logger; +use Icinga\Module\Icingadb\Compat\CompatHost; +use Icinga\Module\Icingadb\Compat\CompatService; +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|CompatService|CompatHost $object The host or service used to resolve the macros + * + * @return string + */ + public function expandMacros(string $input, $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|CompatService|CompatHost $object The host or service used to resolve the macros + * + * @return string + */ + public function resolveMacro(string $macro, $object): string + { + if ($object instanceof Host || (property_exists($object, 'type') && $object->type === '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..b30797b --- /dev/null +++ b/library/Icingadb/Common/ObjectInspectionDetail.php @@ -0,0 +1,348 @@ +<?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\Util\Format; +use Icinga\Util\Json; +use InvalidArgumentException; +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormattedString; +use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Html\Text; +use ipl\Orm\Model; +use ipl\Web\Widget\CopyToClipboard; +use ipl\Web\Widget\EmptyState; + +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' + ]; + + if ($command) { + $execCommand = new HtmlElement('pre', null, Text::create($command)); + CopyToClipboard::attachTo($execCommand); + } else { + $execCommand = new EmptyState(t('n. a.')); + } + + return [ + new HtmlElement('h2', null, Text::create(t('Executed Command'))), + $execCommand, + 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) + { + try { + switch (true) { + case $this->object instanceof Host: + return HostStates::text($state); + case $this->object instanceof Service: + return ServiceStates::text($state); + default: + return $state; + } + } catch (InvalidArgumentException $_) { + // The Icinga 2 API sometimes delivers strange details + return (string) $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); + + if ($value instanceof BaseHtmlElement) { + CopyToClipboard::attachTo($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..c9c5c89 --- /dev/null +++ b/library/Icingadb/Common/StateBadges.php @@ -0,0 +1,195 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\BaseFilter; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBadge; + +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 mixed $content + * @param ?Filter\Rule $filter + * + * @return Link + */ + public function createLink($content, Filter\Rule $filter = null): Link + { + $url = clone $this->getUrl(); + + $urlFilter = Filter::all(); + if ($filter !== null) { + $urlFilter->add($filter); + } + + if ($this->hasBaseFilter()) { + $urlFilter->add($this->getBaseFilter()); + } + + if (! $urlFilter->isEmpty()) { + $url->setFilter($urlFilter); + } + + 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), + Filter::equal($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), + Filter::all( + Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)), + Filter::equal($this->type . '.state.is_handled', 'n'), + Filter::equal($this->type . '.state.is_reachable', 'y') + ) + )); + } + + if (isset($this->item->$handledKey) && $this->item->$handledKey) { + $content[] = Html::tag('li', $this->createLink( + new StateBadge($this->item->$handledKey, $state, true), + Filter::all( + Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)), + Filter::any( + Filter::equal($this->type . '.state.is_handled', 'y'), + Filter::equal($this->type . '.state.is_reachable', 'n') + ) + ) + )); + } + + 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; + } +} diff --git a/library/Icingadb/Compat/CompatHost.php b/library/Icingadb/Compat/CompatHost.php new file mode 100644 index 0000000..12be31f --- /dev/null +++ b/library/Icingadb/Compat/CompatHost.php @@ -0,0 +1,103 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Compat; + +use Icinga\Module\Monitoring\Object\Host; + +class CompatHost extends Host +{ + use CompatObject; + + private $legacyColumns = [ + 'host_action_url' => ['path' => ['action_url', 'action_url']], + 'action_url' => ['path' => ['action_url', 'action_url']], + 'host_address' => ['path' => ['address']], + 'host_address6' => ['path' => ['address6']], + 'host_alias' => ['path' => ['display_name']], + 'host_check_interval' => ['path' => ['check_interval']], + 'host_display_name' => ['path' => ['display_name']], + 'host_icon_image' => ['path' => ['icon_image', 'icon_image']], + 'host_icon_image_alt' => ['path' => ['icon_image_alt']], + 'host_name' => ['path' => ['name']], + 'host_notes' => ['path' => ['notes']], + 'host_notes_url' => ['path' => ['notes_url', 'notes_url']], + 'host_acknowledged' => [ + 'path' => ['state', 'is_acknowledged'], + 'type' => 'bool' + ], + 'host_acknowledgement_type' => [ + 'path' => ['state', 'is_acknowledged'], + 'type' => 'bool' + ], + 'host_active_checks_enabled' => [ + 'path' => ['active_checks_enabled'], + 'type' => 'bool' + ], + 'host_active_checks_enabled_changed' => null, + 'host_attempt' => null, + 'host_check_command' => ['path' => ['checkcommand_name']], + 'host_check_execution_time' => ['path' => ['state', 'execution_time']], + 'host_check_latency' => ['path' => ['state', 'latency']], + 'host_check_source' => ['path' => ['state', 'check_source']], + 'host_check_timeperiod' => ['path' => ['check_timeperiod_name']], + 'host_current_check_attempt' => ['path' => ['state', 'check_attempt']], + 'host_current_notification_number' => null, + 'host_event_handler_enabled' => [ + 'path' => ['event_handler_enabled'], + 'type' => 'bool' + ], + 'host_event_handler_enabled_changed' => null, + 'host_flap_detection_enabled' => [ + 'path' => ['flapping_enabled'], + 'type' => 'bool' + ], + 'host_flap_detection_enabled_changed' => null, + 'host_handled' => [ + 'path' => ['state', 'is_handled'], + 'type' => 'bool' + ], + 'host_in_downtime' => [ + 'path' => ['state', 'in_downtime'], + 'type' => 'bool' + ], + 'host_is_flapping' => [ + 'path' => ['state', 'is_flapping'], + 'type' => 'bool' + ], + 'host_is_reachable' => [ + 'path' => ['state', 'is_reachable'], + 'type' => 'bool' + ], + 'host_last_check' => ['path' => ['state', 'last_update']], + 'host_last_notification' => null, + 'host_last_state_change' => ['path' => ['state', 'last_state_change']], + 'host_long_output' => ['path' => ['state', 'long_output']], + 'host_max_check_attempts' => ['path' => ['max_check_attempts']], + 'host_next_check' => ['path' => ['state', 'next_check']], + 'host_next_update' => ['path' => ['state', 'next_update']], + 'host_notifications_enabled' => [ + 'path' => ['notifications_enabled'], + 'type' => 'bool' + ], + 'host_notifications_enabled_changed' => null, + 'host_obsessing' => null, + 'host_obsessing_changed' => null, + 'host_output' => ['path' => ['state', 'output']], + 'host_passive_checks_enabled' => [ + 'path' => ['passive_checks_enabled'], + 'type' => 'bool' + ], + 'host_passive_checks_enabled_changed' => null, + 'host_percent_state_change' => null, + 'host_perfdata' => ['path' => ['state', 'performance_data']], + 'host_process_perfdata' => [ + 'path' => ['perfdata_enabled'], + 'type' => 'bool' + ], + 'host_state' => ['path' => ['state', 'soft_state']], + 'host_state_type' => ['path' => ['state', 'state_type']], + 'instance_name' => null + ]; +} diff --git a/library/Icingadb/Compat/CompatObject.php b/library/Icingadb/Compat/CompatObject.php new file mode 100644 index 0000000..6a30751 --- /dev/null +++ b/library/Icingadb/Compat/CompatObject.php @@ -0,0 +1,373 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Compat; + +use Icinga\Exception\NotImplementedError; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Model\CustomvarFlat; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Module\Icingadb\Model\Servicegroup; +use Icinga\Module\Icingadb\Model\ServicestateSummary; +use InvalidArgumentException; +use ipl\Orm\Model; +use ipl\Orm\Query; +use ipl\Stdlib\Filter; +use LogicException; + +use function ipl\Stdlib\get_php_type; + +trait CompatObject +{ + use Auth; + use Database; + + /** @var array Non-obscured custom variables */ + protected $rawCustomvars; + + /** @var array Non-obscured host custom variables */ + protected $rawHostCustomvars; + + /** @var Model $object */ + private $object; + + public function __construct(Model $object) + { + $this->object = $object; + } + + public static function fromModel(Model $object) + { + switch (true) { + case $object instanceof Host: + return new CompatHost($object); + case $object instanceof Service: + return new CompatService($object); + default: + throw new InvalidArgumentException(sprintf( + 'Host or Service Model instance expected, got "%s" instead.', + get_php_type($object) + )); + } + } + + /** + * Get this object's name + * + * @return string + */ + public function getName(): string + { + return $this->object->name; + } + + public function fetch(): bool + { + return true; + } + + protected function fetchRawCustomvars(): self + { + if ($this->rawCustomvars !== null) { + return $this; + } + + $vars = $this->object->customvar->execute(); + + $customVars = []; + foreach ($vars as $row) { + $customVars[$row->name] = $row->value; + } + + $this->rawCustomvars = $customVars; + + return $this; + } + + protected function fetchRawHostCustomvars(): self + { + if ($this->rawHostCustomvars !== null) { + return $this; + } + + $vars = $this->object->host->customvar->execute(); + + $customVars = []; + foreach ($vars as $row) { + $customVars[$row->name] = $row->value; + } + + $this->rawHostCustomvars = $customVars; + + return $this; + } + + public function fetchComments() + { + $this->comments = []; + + return $this; + } + + public function fetchContactgroups() + { + $this->contactgroups = []; + + return $this; + } + + public function fetchContacts() + { + $this->contacts = []; + + return $this; + } + + public function fetchCustomvars(): self + { + if ($this->customvars !== null) { + return $this; + } + + $this->customvars = (new CustomvarFlat())->unFlattenVars($this->object->customvar_flat); + + return $this; + } + + public function fetchHostVariables() + { + if (isset($this->hostVariables)) { + return $this; + } + + $this->hostVariables = []; + foreach ($this->object->customvar as $customvar) { + $this->hostVariables[strtolower($customvar->name)] = json_decode($customvar->value); + } + + return $this; + } + + public function fetchServiceVariables() + { + if (isset($this->serviceVariables)) { + return $this; + } + + $this->serviceVariables = []; + foreach ($this->object->customvar as $customvar) { + $this->serviceVariables[strtolower($customvar->name)] = json_decode($customvar->value); + } + + return $this; + } + + public function fetchDowntimes() + { + $this->downtimes = []; + + return $this; + } + + public function fetchEventhistory() + { + $this->eventhistory = []; + + return $this; + } + + public function fetchHostgroups() + { + if ($this->type === self::TYPE_HOST) { + $hostname = $this->object->name; + $hostgroupQuery = clone $this->object->hostgroup; + } else { + $hostname = $this->object->host->name; + $hostgroupQuery = clone $this->object->host->hostgroup; + } + + $hostgroupQuery + ->columns(['name', 'display_name']) + ->filter(Filter::equal('host.name', $hostname)); + + /** @var Query $hostgroupQuery */ + $this->hostgroups = []; + foreach ($hostgroupQuery as $hostgroup) { + $this->hostgroups[$hostgroup->name] = $hostgroup->display_name; + } + + return $this; + } + + public function fetchServicegroups() + { + if ($this->type === self::TYPE_HOST) { + $hostname = $this->object->name; + $query = Servicegroup::on($this->getDb()); + } else { + $hostname = $this->object->host->name; + $query = (clone $this->object->servicegroup); + } + + $query + ->columns(['name', 'display_name']) + ->filter(Filter::equal('host.name', $hostname)); + + if ($this->type === self::TYPE_SERVICE) { + $query->filter(Filter::equal('service.name', $this->object->name)); + } + + $this->servicegroups = []; + foreach ($query as $serviceGroup) { + $this->servicegroups[$serviceGroup->name] = $serviceGroup->display_name; + } + + return $this; + } + + public function fetchStats() + { + $query = ServicestateSummary::on($this->getDb()); + + if ($this->type === self::TYPE_HOST) { + $query->filter(Filter::equal('host.name', $this->object->name)); + } else { + $query->filter(Filter::all( + Filter::equal('host.name', $this->object->host->name), + Filter::equal('service.name', $this->object->name) + )); + } + + $result = $query->first(); + + $this->stats = (object) [ + 'services_total' => $result->services_total, + 'services_ok' => $result->services_ok, + 'services_critical' => $result->services_critical_handled + $result->services_critical_unhandled, + 'services_critical_unhandled' => $result->services_critical_unhandled, + 'services_critical_handled' => $result->services_critical_handled, + 'services_warning' => $result->services_warning_handled + $result->services_warning_unhandled, + 'services_warning_unhandled' => $result->services_warning_unhandled, + 'services_warning_handled' => $result->services_warning_handled, + 'services_unknown' => $result->services_unknown_handled + $result->services_unknown_unhandled, + 'services_unknown_unhandled' => $result->services_unknown_unhandled, + 'services_unknown_handled' => $result->services_unknown_handled, + 'services_pending' => $result->services_pending + ]; + + return $this; + } + + public function __get($name) + { + if (property_exists($this, $name)) { + if ($this->$name === null) { + $fetchMethod = 'fetch' . ucfirst($name); + $this->$fetchMethod(); + } + + return $this->$name; + } + + if (preg_match('/^_(host|service)_(.+)/i', $name, $matches)) { + switch (strtolower($matches[1])) { + case $this->type: + $customvars = $this->fetchRawCustomvars()->rawCustomvars; + break; + case self::TYPE_HOST: + $customvars = $this->fetchRawHostCustomvars()->rawHostCustomvars; + break; + case self::TYPE_SERVICE: + throw new LogicException('Cannot fetch service custom variables for non-service objects'); + } + + $variableName = strtolower($matches[2]); + if (isset($customvars[$variableName])) { + return $customvars[$variableName]; + } + + return null; // Unknown custom variables MUST NOT throw an error + } + + if (! array_key_exists($name, $this->legacyColumns) && ! $this->object->hasProperty($name)) { + if (isset($this->customvars[$name])) { + return $this->customvars[$name]; + } + + if (substr($name, 0, strlen($this->prefix)) !== $this->prefix) { + $name = $this->prefix . $name; + } + } + + if (array_key_exists($name, $this->legacyColumns)) { + $opts = $this->legacyColumns[$name]; + if ($opts === null) { + return null; + } + + $path = $opts['path']; + $value = null; + + if (! empty($path)) { + $value = $this->object; + + do { + $col = array_shift($path); + $value = $value->$col; + } while (! empty($path) && $value !== null); + } + + if (isset($opts['type'])) { + $method = 'get' . ucfirst($opts['type']) . 'Type'; + $value = $this->$method($value); + } + + return $value; + } + + return $this->object->$name; + } + + public function __isset($name) + { + if (property_exists($this, $name)) { + return isset($this->$name); + } + + if (isset($this->legacyColumns[$name]) || isset($this->object->$name)) { + return true; + } + + return false; + } + + /** + * @throws NotImplementedError Don't use! + */ + protected function getDataView() + { + throw new NotImplementedError('getDataView() is not supported'); + } + + /** + * Get the bool type of the given value as an int + * + * @param bool|string $value + * + * @return ?int + */ + private function getBoolType($value) + { + switch ($value) { + case false: + return 0; + case true: + return 1; + case 'sticky': + return 2; + } + } +} diff --git a/library/Icingadb/Compat/CompatService.php b/library/Icingadb/Compat/CompatService.php new file mode 100644 index 0000000..7fe599e --- /dev/null +++ b/library/Icingadb/Compat/CompatService.php @@ -0,0 +1,156 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Compat; + +use Icinga\Module\Monitoring\Object\Service; + +class CompatService extends Service +{ + use CompatObject; + + private $legacyColumns = [ + 'instance_name' => null, + 'host_attempt' => null, + 'host_icon_image' => ['path' => ['host', 'icon_image', 'icon_image']], + 'host_icon_image_alt' => ['path' => ['host', 'icon_image_alt']], + 'host_acknowledged' => [ + 'path' => ['host', 'state', 'is_acknowledged'], + 'type' => 'bool' + ], + 'host_active_checks_enabled' => [ + 'path' => ['host', 'active_checks_enabled'], + 'type' => 'bool' + ], + 'host_address' => ['path' => ['host', 'address']], + 'host_address6' => ['path' => ['host', 'address6']], + 'host_alias' => ['path' => ['host', 'display_name']], + 'host_display_name' => ['path' => ['host', 'display_name']], + 'host_handled' => [ + 'path' => ['host', 'state', 'is_handled'], + 'type' => 'bool' + ], + 'host_in_downtime' => [ + 'path' => ['host', 'state', 'in_downtime'], + 'type' => 'bool' + ], + 'host_is_flapping' => [ + 'path' => ['host', 'state', 'is_flapping'], + 'type' => 'bool' + ], + 'host_last_state_change' => ['path' => ['host', 'state', 'last_state_change']], + 'host_name' => ['path' => ['host', 'name']], + 'host_notifications_enabled' => [ + 'path' => ['host', 'notifications_enabled'], + 'type' => 'bool' + ], + 'host_passive_checks_enabled' => [ + 'path' => ['host', 'passive_checks_enabled'], + 'type' => 'bool' + ], + 'host_state' => ['path' => ['host', 'state', 'soft_state']], + 'host_state_type' => ['path' => ['host', 'state', 'state_type']], + 'service_icon_image' => ['path' => ['icon_image', 'icon_image']], + 'service_icon_image_alt' => ['path' => ['icon_image_alt']], + 'service_acknowledged' => [ + 'path' => ['state', 'is_acknowledged'], + 'type' => 'bool' + ], + 'service_acknowledgement_type' => [ + 'path' => ['state', 'is_acknowledged'], + 'type' => 'bool' + ], + 'service_action_url' => ['path' => ['action_url', 'action_url']], + 'action_url' => ['path' => ['action_url', 'action_url']], + 'service_active_checks_enabled' => [ + 'path' => ['active_checks_enabled'], + 'type' => 'bool' + ], + 'service_active_checks_enabled_changed' => null, + 'service_attempt' => null, + 'service_check_command' => ['path' => ['checkcommand_name']], + 'service_check_execution_time' => ['path' => ['state', 'execution_time']], + 'service_check_interval' => ['path' => ['check_interval']], + 'service_check_latency' => ['path' => ['state', 'latency']], + 'service_check_source' => ['path' => ['state', 'check_source']], + 'service_check_timeperiod' => ['path' => ['check_timeperiod_name']], + 'service_current_notification_number' => null, + 'service_description' => ['path' => ['name']], + 'service_display_name' => ['path' => ['display_name']], + 'service_event_handler_enabled' => [ + 'path' => ['event_handler_enabled'], + 'type' => 'bool' + ], + 'service_event_handler_enabled_changed' => null, + 'service_flap_detection_enabled' => [ + 'path' => ['flapping_enabled'], + 'type' => 'bool' + ], + 'service_flap_detection_enabled_changed' => null, + 'service_handled' => [ + 'path' => ['state', 'is_handled'], + 'type' => 'bool' + ], + 'service_in_downtime' => [ + 'path' => ['state', 'in_downtime'], + 'type' => 'bool' + ], + 'service_is_flapping' => [ + 'path' => ['state', 'is_flapping'], + 'type' => 'bool' + ], + 'service_is_reachable' => [ + 'path' => ['state', 'is_reachable'], + 'type' => 'bool' + ], + 'service_last_check' => ['path' => ['state', 'last_update']], + 'service_last_notification' => null, + 'service_last_state_change' => ['path' => ['state', 'last_state_change']], + 'service_long_output' => ['path' => ['state', 'long_output']], + 'service_next_check' => ['path' => ['state', 'next_check']], + 'service_next_update' => ['path' => ['state', 'next_update']], + 'service_notes' => ['path' => ['notes']], + 'service_notes_url' => ['path' => ['notes_url', 'notes_url']], + 'service_notifications_enabled' => [ + 'path' => ['notifications_enabled'], + 'type' => 'bool' + ], + 'service_notifications_enabled_changed' => null, + 'service_obsessing' => null, + 'service_obsessing_changed' => null, + 'service_output' => ['path' => ['state', 'output']], + 'service_passive_checks_enabled' => [ + 'path' => ['passive_checks_enabled'], + 'type' => 'bool' + ], + 'service_passive_checks_enabled_changed' => null, + 'service_percent_state_change' => null, + 'service_perfdata' => ['path' => ['state', 'performance_data']], + 'service_process_perfdata' => [ + 'path' => ['perfdata_enabled'], + 'type' => 'bool' + ], + 'service_state' => ['path' => ['state', 'soft_state']], + 'service_state_type' => ['path' => ['state', 'state_type']] + ]; + + /** + * Get this service's host + * + * @return CompatHost + */ + public function getHost(): CompatHost + { + if ($this->host === null) { + $this->host = new CompatHost($this->object->host); + } + + return $this->host; + } + + protected function fetchHost() + { + $this->getHost(); + } +} diff --git a/library/Icingadb/Compat/UrlMigrator.php b/library/Icingadb/Compat/UrlMigrator.php new file mode 100644 index 0000000..47780be --- /dev/null +++ b/library/Icingadb/Compat/UrlMigrator.php @@ -0,0 +1,1353 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Compat; + +use Icinga\Web\UrlParams; +use InvalidArgumentException; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; + +class UrlMigrator +{ + const NO_YES = ['n', 'y']; + const USE_EXPR = 'use-expr'; + const SORT_ONLY = 'sort-only'; + const LOWER_EXPR = 'lower-expr'; + const DROP = 'drop'; + + const SUPPORTED_PATHS = [ + 'monitoring/list/hosts' => ['hosts', 'icingadb/hosts'], + 'monitoring/hosts/show' => ['multipleHosts', 'icingadb/hosts/details'], + 'monitoring/host/show' => ['host', 'icingadb/host'], + 'monitoring/host/services' => ['host', 'icingadb/host/services'], + 'monitoring/host/history' => ['host', 'icingadb/host/history'], + 'monitoring/list/services' => ['services', 'icingadb/services'], + 'monitoring/list/servicegrid' => ['servicegrid', 'icingadb/services/grid'], + 'monitoring/services/show' => ['multipleServices', 'icingadb/services/details'], + 'monitoring/service/show' => ['service', 'icingadb/service'], + 'monitoring/service/history' => ['service', 'icingadb/service/history'], + 'monitoring/list/hostgroups' => ['hostgroups', 'icingadb/hostgroups'], + 'monitoring/list/servicegroups' => ['servicegroups', 'icingadb/servicegroups'], + 'monitoring/list/contactgroups' => ['contactgroups', 'icingadb/usergroups'], + 'monitoring/list/contacts' => ['contacts', 'icingadb/users'], + 'monitoring/list/comments' => ['comments', 'icingadb/comments'], + 'monitoring/list/downtimes' => ['downtimes', 'icingadb/downtimes'], + 'monitoring/list/eventhistory' => ['history', 'icingadb/history'], + 'monitoring/list/notifications' => ['notificationHistory', 'icingadb/notifications'], + 'monitoring/health/info' => [null, 'icingadb/health'], + 'monitoring/health/stats' => [null, 'icingadb/health'], + 'monitoring/tactical' => ['services', 'icingadb/tactical'] + ]; + + public static function isSupportedUrl(Url $url): bool + { + $supportedPaths = self::SUPPORTED_PATHS; + return isset($supportedPaths[ltrim($url->getPath(), '/')]); + } + + public static function hasParamTransformer(string $name): bool + { + return method_exists(new self(), $name . 'Parameters'); + } + + public static function hasQueryTransformer(string $name): bool + { + return method_exists(new self(), $name . 'Columns'); + } + + public static function transformUrl(Url $url): Url + { + if (! self::isSupportedUrl($url)) { + throw new InvalidArgumentException(sprintf('Url path "%s" is not supported', $url->getPath())); + } + + list($transformer, $dbRoute) = self::SUPPORTED_PATHS[ltrim($url->getPath(), '/')]; + + $url = clone $url; + $url->setPath($dbRoute); + + if (! $url->getParams()->isEmpty()) { + [$params, $filter] = self::transformParams($url, $transformer); + $url->setParams($params); + + if (! $filter->isEmpty()) { + $filter = QueryString::parse((string) $filter); + $filter = self::transformFilter($filter, $transformer); + $url->setFilter($filter ?: null); + } + } + + return $url; + } + + public static function transformParams(Url $url, string $transformerName = null): array + { + $transformer = new self(); + + $params = self::commonParameters(); + $columns = self::commonColumns(); + + if ($transformerName !== null) { + if (! self::hasQueryTransformer($transformerName)) { + throw new InvalidArgumentException(sprintf('Transformer "%s" is not supported', $transformerName)); + } + + if (self::hasParamTransformer($transformerName)) { + $params = array_merge($params, $transformer->{$transformerName . 'Parameters'}()); + } + + $columns = array_merge($columns, $transformer->{$transformerName . 'Columns'}()); + } + + $columnRewriter = function ($column) use ($columns, $transformer) { + $rewritten = $transformer->rewrite(Filter::equal($column, 'bogus'), $columns); + if ($rewritten === false) { + return false; + } elseif ($rewritten instanceof Filter\Condition) { + return $rewritten->getColumn(); + } + + return $column; + }; + + $urlParams = $url->onlyWith(array_keys($params))->getParams(); + $urlFilter = $url->without(array_keys($params))->getParams(); + + $newParams = new UrlParams(); + foreach ($urlParams->toArray(false) as $name => $value) { + if (is_int($name)) { + $name = $value; + $value = true; + } else { + $value = rawurldecode($value); + } + + $name = rawurldecode($name); + + if (! isset($params[$name]) || $params[$name] === self::USE_EXPR) { + $newParams->add($name, $value); + } elseif ($params[$name] === self::DROP) { + // pass + } elseif (is_callable($params[$name])) { + $result = $params[$name]($value, $urlParams, $columnRewriter); + if ($result === false) { + continue; + } elseif (is_array($result)) { + [$name, $value] = $result; + } elseif ($result !== null) { + $value = $result; + } + + $newParams->add($name, $value); + } + } + + return [$newParams, $urlFilter]; + } + + /** + * Transform the given legacy filter + * + * @param Filter\Rule $filter + * @param string|null $queryTransformer + * + * @return Filter\Rule|false + */ + public static function transformFilter(Filter\Rule $filter, string $queryTransformer = null) + { + $transformer = new self(); + + $columns = $transformer::commonColumns(); + if ($queryTransformer !== null) { + if (! self::hasQueryTransformer($queryTransformer)) { + throw new InvalidArgumentException(sprintf('Transformer "%s" is not supported', $queryTransformer)); + } + + $columns = array_merge($columns, $transformer->{$queryTransformer . 'Columns'}()); + } + + $rewritten = $transformer->rewrite($filter, $columns); + return $rewritten === false ? false : ($rewritten instanceof Filter\Rule ? $rewritten : $filter); + } + + /** + * Transform given legacy wildcard filters + * + * @param $filter Filter\Rule + * + * @return Filter\Chain|Filter\Condition + */ + public static function transformLegacyWildcardFilter(Filter\Rule $filter) + { + if ($filter instanceof Filter\Chain) { + foreach ($filter as $child) { + $newChild = self::transformLegacyWildcardFilter($child); + if ($newChild !== $child) { + $filter->replace($child, $newChild); + } + } + + return $filter; + } else { + /** @var Filter\Condition $filter */ + return self::transformWildcardFilter($filter); + } + } + + /** + * Rewrite the given filter and legacy columns + * + * @param Filter\Rule $filter + * @param array $legacyColumns + * + * @return ?mixed + */ + protected function rewrite(Filter\Rule $filter, array $legacyColumns) + { + $rewritten = null; + if ($filter instanceof Filter\Condition) { + $column = $filter->getColumn(); + + $modelPath = null; + $exprRule = null; + if (isset($legacyColumns[$column])) { + if ($legacyColumns[$column] === self::DROP) { + return false; + } elseif (is_callable($legacyColumns[$column])) { + return $legacyColumns[$column]($filter); + } elseif (! is_array($legacyColumns[$column])) { + return null; + } + + foreach ($legacyColumns[$column] as $modelPath => $exprRule) { + break; + } + + $rewritten = $filter->setColumn($modelPath); + + switch (true) { + case $exprRule === self::USE_EXPR: + break; + case $exprRule === self::LOWER_EXPR: + $filter->setValue(strtolower($filter->getValue())); + break; + case is_array($exprRule) && isset($exprRule[$filter->getValue()]): + $filter->setValue($exprRule[$filter->getValue()]); + break; + default: + $filter->setValue($exprRule); + } + + $rewritten = self::transformWildcardFilter($rewritten); + } elseif (preg_match('/^_(host|service)_(.+)/i', $column, $groups)) { + $rewritten = $filter->setColumn($groups[1] . '.vars.' . $groups[2]); + $rewritten = self::transformWildcardFilter($rewritten); + } + } else { + /** @var Filter\Chain $filter */ + foreach ($filter as $child) { + $retVal = $this->rewrite( + $child instanceof Filter\Condition ? clone $child : $child, + $legacyColumns + ); + if ($retVal === false) { + $filter->remove($child); + } elseif ($retVal instanceof Filter\Rule) { + $filter->replace($child, $retVal); + } + } + } + + return $rewritten; + } + + private static function transformWildcardFilter(Filter\Condition $filter) + { + if (is_string($filter->getValue()) && strpos($filter->getValue(), '*') !== false) { + if ($filter instanceof Filter\Equal) { + return Filter::like($filter->getColumn(), $filter->getValue()); + } elseif ($filter instanceof Filter\Unequal) { + return Filter::unlike($filter->getColumn(), $filter->getValue()); + } + } + + return $filter; + } + + protected static function commonParameters(): array + { + return [ + 'sort' => function ($value, $params, $rewriter) { + $value = $rewriter($value); + if ($params->has('dir')) { + return "{$value} {$params->get('dir')}"; + } + + return $value; + }, + 'dir' => self::DROP, + 'limit' => self::USE_EXPR, + 'showCompact' => self::USE_EXPR, + 'showFullscreen' => self::USE_EXPR, + 'view' => function ($value) { + if ($value === 'compact') { + return ['showCompact', true]; + } + + return $value; + } + ]; + } + + protected static function commonColumns(): array + { + return [ + + // Filter columns + 'host' => [ + 'host.name_ci' => self::USE_EXPR + ], + 'host_display_name' => [ + 'host.display_name' => self::USE_EXPR + ], + 'host_alias' => self::DROP, + 'hostgroup' => [ + 'hostgroup.name_ci' => self::USE_EXPR + ], + 'hostgroup_alias' => [ + 'hostgroup.display_name' => self::USE_EXPR + ], + 'service' => [ + 'service.name_ci' => self::USE_EXPR + ], + 'service_display_name' => [ + 'service.display_name' => self::USE_EXPR + ], + 'servicegroup' => [ + 'servicegroup.name_ci' => self::USE_EXPR + ], + 'servicegroup_alias' => [ + 'servicegroup.display_name' => self::USE_EXPR + ], + + // Restriction columns + 'instance_name' => self::DROP, + 'host_name' => [ + 'host.name' => self::USE_EXPR + ], + 'hostgroup_name' => [ + 'hostgroup.name' => self::USE_EXPR + ], + 'service_description' => [ + 'service.name' => self::USE_EXPR + ], + 'servicegroup_name' => [ + 'servicegroup.name' => self::USE_EXPR + ] + ]; + } + + protected static function hostsParameters(): array + { + return [ + 'addColumns' => function ($value, $params, $rewriter) { + $legacyColumns = array_filter(array_map('trim', explode(',', $value))); + + $columns = [ + 'host.state.soft_state', + 'host.state.last_state_change', + 'host.icon_image.icon_image', + 'host.display_name', + 'host.state.output', + 'host.state.performance_data', + 'host.state.is_problem' + ]; + foreach ($legacyColumns as $column) { + $column = $rewriter($column); + if ($column !== false) { + $columns[] = $column; + } + } + + return ['columns', implode(',', $columns)]; + } + ]; + } + + protected static function hostsColumns(): array + { + return [ + + // Query columns + 'host_acknowledged' => [ + 'host.state.is_acknowledged' => self::NO_YES + ], + 'host_acknowledgement_type' => [ + 'host.state.is_acknowledged' => array_merge(self::NO_YES, ['sticky']) + ], + 'host_action_url' => [ + 'host.action_url.action_url' => self::USE_EXPR + ], + 'host_active_checks_enabled' => [ + 'host.active_checks_enabled' => self::NO_YES + ], + 'host_active_checks_enabled_changed' => self::DROP, + 'host_address' => [ + 'host.address' => self::USE_EXPR + ], + 'host_address6' => [ + 'host.address6' => self::USE_EXPR + ], + 'host_alias' => self::DROP, + 'host_check_command' => [ + 'host.checkcommand_name' => self::USE_EXPR + ], + 'host_check_execution_time' => [ + 'host.state.execution_time' => self::USE_EXPR + ], + 'host_check_latency' => [ + 'host.state.latency' => self::USE_EXPR + ], + 'host_check_source' => [ + 'host.state.check_source' => self::USE_EXPR + ], + 'host_check_timeperiod' => [ + 'host.check_timeperiod_name' => self::USE_EXPR + ], + 'host_current_check_attempt' => [ + 'host.state.check_attempt' => self::USE_EXPR + ], + 'host_current_notification_number' => self::DROP, + 'host_display_name' => [ + 'host.display_name' => self::USE_EXPR + ], + 'host_event_handler_enabled' => [ + 'host.event_handler_enabled' => self::NO_YES + ], + 'host_event_handler_enabled_changed' => self::DROP, + 'host_flap_detection_enabled' => [ + 'host.flapping_enabled' => self::NO_YES + ], + 'host_flap_detection_enabled_changed' => self::DROP, + 'host_handled' => [ + 'host.state.is_handled' => self::NO_YES + ], + 'host_hard_state' => [ + 'host.state.hard_state' => self::USE_EXPR + ], + 'host_in_downtime' => [ + 'host.state.in_downtime' => self::NO_YES + ], + 'host_ipv4' => [ + 'host.address_bin' => self::USE_EXPR + ], + 'host_is_flapping' => [ + 'host.state.is_flapping' => self::NO_YES + ], + 'host_is_reachable' => [ + 'host.state.is_reachable' => self::NO_YES + ], + 'host_last_check' => [ + 'host.state.last_update' => self::USE_EXPR + ], + 'host_last_notification' => self::DROP, + 'host_last_state_change' => [ + 'host.state.last_state_change' => self::USE_EXPR + ], + 'host_last_state_change_ts' => [ + 'host.state.last_state_change' => self::USE_EXPR + ], + 'host_long_output' => [ + 'host.state.long_output' => self::USE_EXPR + ], + 'host_max_check_attempts' => [ + 'host.max_check_attempts' => self::USE_EXPR + ], + 'host_modified_host_attributes' => self::DROP, + 'host_name' => [ + 'host.name' => self::USE_EXPR + ], + 'host_next_check' => [ + 'host.state.next_check' => self::USE_EXPR + ], + 'host_notes_url' => [ + 'host.notes_url.notes_url' => self::USE_EXPR + ], + 'host_notifications_enabled' => [ + 'host.notifications_enabled' => self::NO_YES + ], + 'host_notifications_enabled_changed' => self::DROP, + 'host_obsessing' => self::DROP, + 'host_obsessing_changed' => self::DROP, + 'host_output' => [ + 'host.state.output' => self::USE_EXPR + ], + 'host_passive_checks_enabled' => [ + 'host.passive_checks_enabled' => self::NO_YES + ], + 'host_passive_checks_enabled_changed' => self::DROP, + 'host_percent_state_change' => self::DROP, + 'host_perfdata' => [ + 'host.state.performance_data' => self::USE_EXPR + ], + 'host_problem' => [ + 'host.state.is_problem' => self::NO_YES + ], + 'host_severity' => [ + 'host.state.severity' => self::USE_EXPR + ], + 'host_state' => [ + 'host.state.soft_state' => self::USE_EXPR + ], + 'host_state_type' => [ + 'host.state.state_type' => ['soft', 'hard'] + ], + 'host_unhandled' => [ + 'host.state.is_handled' => array_reverse(self::NO_YES) + ], + + // Filter columns + 'host_contact' => [ + 'host.user.name' => self::USE_EXPR + ], + 'host_contactgroup' => [ + 'host.usergroup.name' => self::USE_EXPR + ], + + // Query columns the dataview doesn't include, added here because it's possible to filter for them anyway + 'host_check_interval' => self::DROP, + 'host_icon_image' => self::DROP, + 'host_icon_image_alt' => self::DROP, + 'host_notes' => self::DROP, + 'object_type' => self::DROP, + 'object_id' => self::DROP, + 'host_attempt' => self::DROP, + 'host_check_type' => self::DROP, + 'host_event_handler' => self::DROP, + 'host_failure_prediction_enabled' => self::DROP, + 'host_is_passive_checked' => self::DROP, + 'host_last_hard_state' => self::DROP, + 'host_last_hard_state_change' => self::DROP, + 'host_last_time_down' => self::DROP, + 'host_last_time_unreachable' => self::DROP, + 'host_last_time_up' => self::DROP, + 'host_next_notification' => self::DROP, + 'host_next_update' => function ($filter) { + /** @var Filter\Condition $filter */ + if ($filter->getValue() !== 'now') { + return false; + } + + // Doesn't get dropped because there's a default dashlet using it.. + // Though since this dashlet uses it to check for overdue hosts we'll + // replace it as next_update is volatile (only in redis up2date) + return Filter::equal('host.state.is_overdue', $filter instanceof Filter\LessThan ? 'y' : 'n'); + }, + 'host_no_more_notifications' => self::DROP, + 'host_normal_check_interval' => self::DROP, + 'host_problem_has_been_acknowledged' => self::DROP, + 'host_process_performance_data' => self::DROP, + 'host_retry_check_interval' => self::DROP, + 'host_scheduled_downtime_depth' => self::DROP, + 'host_status_update_time' => self::DROP, + 'problems' => self::DROP + ]; + } + + protected static function multipleHostsColumns(): array + { + return array_merge( + static::hostsColumns(), + [ + 'host' => [ + 'host.name' => self::USE_EXPR + ] + ] + ); + } + + protected static function hostColumns(): array + { + return [ + 'host' => [ + 'name' => self::USE_EXPR + ] + ]; + } + + protected static function servicesParameters(): array + { + return [ + 'addColumns' => function ($value, $params, $rewriter) { + $legacyColumns = array_filter(array_map('trim', explode(',', $value))); + + $columns = [ + 'service.state.soft_state', + 'service.state.last_state_change', + 'service.icon_image.icon_image', + 'service.display_name', + 'service.host.display_name', + 'service.state.output', + 'service.state.performance_data', + 'service.state.is_problem' + ]; + foreach ($legacyColumns as $column) { + $column = $rewriter($column); + if ($column !== false) { + $columns[] = $column; + } + } + + return ['columns', implode(',', $columns)]; + } + ]; + } + + protected static function servicesColumns(): array + { + return [ + // Query columns + 'host_acknowledged' => [ + 'host.state.is_acknowledged' => self::NO_YES + ], + 'host_action_url' => [ + 'host.action_url.action_url' => self::USE_EXPR + ], + 'host_active_checks_enabled' => [ + 'host.active_checks_enabled' => self::NO_YES + ], + 'host_address' => [ + 'host.address' => self::USE_EXPR + ], + 'host_address6' => [ + 'host.address6' => self::USE_EXPR + ], + 'host_alias' => self::DROP, + 'host_check_source' => [ + 'host.state.check_source' => self::USE_EXPR + ], + 'host_display_name' => [ + 'host.display_name' => self::USE_EXPR + ], + 'host_handled' => [ + 'host.state.is_handled' => self::NO_YES + ], + 'host_hard_state' => [ + 'host.state.hard_state' => self::USE_EXPR + ], + 'host_in_downtime' => [ + 'host.state.in_downtime' => self::NO_YES + ], + 'host_ipv4' => [ + 'host.address_bin' => self::USE_EXPR + ], + 'host_is_flapping' => [ + 'host.state.is_flapping' => self::NO_YES + ], + 'host_last_check' => [ + 'host.state.last_update' => self::USE_EXPR + ], + 'host_last_hard_state' => [ + 'host.state.previous_hard_state' => self::USE_EXPR + ], + 'host_last_hard_state_change' => self::DROP, + 'host_last_state_change' => [ + 'host.state.last_state_change' => self::USE_EXPR + ], + 'host_last_time_down' => self::DROP, + 'host_last_time_unreachable' => self::DROP, + 'host_last_time_up' => self::DROP, + 'host_long_output' => [ + 'host.state.long_output' => self::USE_EXPR + ], + 'host_modified_host_attributes' => self::DROP, + 'host_notes_url' => [ + 'host.notes_url.notes_url' => self::USE_EXPR + ], + 'host_notifications_enabled' => [ + 'host.notifications_enabled' => self::NO_YES + ], + 'host_output' => [ + 'host.state.output' => self::USE_EXPR + ], + 'host_passive_checks_enabled' => [ + 'host.passive_checks_enabled' => self::NO_YES + ], + 'host_perfdata' => [ + 'host.state.performance_data' => self::USE_EXPR + ], + 'host_problem' => [ + 'host.state.is_problem' => self::NO_YES + ], + 'host_severity' => [ + 'host.state.severity' => self::USE_EXPR + ], + 'host_state' => [ + 'host.state.soft_state' => self::USE_EXPR + ], + 'host_state_type' => [ + 'host.state.state_type' => ['soft', 'hard'] + ], + 'service_acknowledged' => [ + 'service.state.is_acknowledged' => self::NO_YES + ], + 'service_acknowledgement_type' => [ + 'service.state.is_acknowledged' => array_merge(self::NO_YES, ['sticky']) + ], + 'service_action_url' => [ + 'service.action_url.action_url' => self::USE_EXPR + ], + 'service_active_checks_enabled' => [ + 'service.active_checks_enabled' => self::NO_YES + ], + 'service_active_checks_enabled_changed' => self::DROP, + 'service_attempt' => [ + 'service.state.check_attempt' => self::USE_EXPR + ], + 'service_check_command' => [ + 'service.checkcommand_name' => self::USE_EXPR + ], + 'service_check_source' => [ + 'service.state.check_source' => self::USE_EXPR + ], + 'service_check_timeperiod' => [ + 'service.check_timeperiod_name' => self::USE_EXPR + ], + 'service_current_check_attempt' => [ + 'service.state.check_attempt' => self::USE_EXPR + ], + 'service_current_notification_number' => self::DROP, + 'service_display_name' => [ + 'service.display_name' => self::USE_EXPR + ], + 'service_event_handler_enabled' => [ + 'service.event_handler_enabled' => self::NO_YES + ], + 'service_event_handler_enabled_changed' => self::DROP, + 'service_flap_detection_enabled' => [ + 'service.flapping_enabled' => self::NO_YES + ], + 'service_flap_detection_enabled_changed' => self::DROP, + 'service_handled' => [ + 'service.state.is_handled' => self::NO_YES + ], + 'service_hard_state' => [ + 'service.state.hard_state' => self::USE_EXPR + ], + 'service_host_name' => [ + 'host.name' => self::USE_EXPR + ], + 'service_in_downtime' => [ + 'service.state.in_downtime' => self::NO_YES + ], + 'service_is_flapping' => [ + 'service.state.is_flapping' => self::NO_YES + ], + 'service_is_reachable' => [ + 'service.state.is_reachable' => self::NO_YES + ], + 'service_last_check' => [ + 'service.state.last_update' => self::USE_EXPR + ], + 'service_last_hard_state' => [ + 'service.state.previous_hard_state' => self::USE_EXPR + ], + 'service_last_hard_state_change' => self::DROP, + 'service_last_notification' => self::DROP, + 'service_last_state_change' => [ + 'service.state.last_state_change' => self::USE_EXPR + ], + 'service_last_state_change_ts' => [ + 'service.state.last_state_change' => self::USE_EXPR + ], + 'service_last_time_critical' => self::DROP, + 'service_last_time_ok' => self::DROP, + 'service_last_time_unknown' => self::DROP, + 'service_last_time_warning' => self::DROP, + 'service_long_output' => [ + 'service.state.long_output' => self::USE_EXPR + ], + 'service_max_check_attempts' => [ + 'service.max_check_attempts' => self::USE_EXPR + ], + 'service_modified_service_attributes' => self::DROP, + 'service_next_check' => [ + 'service.state.next_check' => self::USE_EXPR + ], + 'service_notes' => [ + 'service.notes' => self::USE_EXPR + ], + 'service_notes_url' => [ + 'service.notes_url.notes_url' => self::USE_EXPR + ], + 'service_notifications_enabled' => [ + 'service.notifications_enabled' => self::NO_YES + ], + 'service_notifications_enabled_changed' => self::DROP, + 'service_obsessing' => self::DROP, + 'service_obsessing_changed' => self::DROP, + 'service_output' => [ + 'service.state.output' => self::USE_EXPR + ], + 'service_passive_checks_enabled' => [ + 'service.passive_checks_enabled' => self::USE_EXPR + ], + 'service_passive_checks_enabled_changed' => self::DROP, + 'service_perfdata' => [ + 'service.state.performance_data' => self::USE_EXPR + ], + 'service_problem' => [ + 'service.state.is_problem' => self::NO_YES + ], + 'service_severity' => [ + 'service.state.severity' => self::USE_EXPR + ], + 'service_state' => [ + 'service.state.soft_state' => self::USE_EXPR + ], + 'service_state_type' => [ + 'service.state.state_type' => ['soft', 'hard'] + ], + 'service_unhandled' => [ + 'service.state.is_handled' => array_reverse(self::NO_YES) + ], + + // Filter columns + 'host_contact' => [ + 'host.user.name' => self::USE_EXPR + ], + 'host_contactgroup' => [ + 'host.usergroup.name' => self::USE_EXPR + ], + 'service_contact' => [ + 'service.user.name' => self::USE_EXPR + ], + 'service_contactgroup' => [ + 'service.usergroup.name' => self::USE_EXPR + ], + 'service_host' => [ + 'host.name_ci' => self::USE_EXPR + ], + + // Query columns the dataview doesn't include, added here because it's possible to filter for them anyway + 'host_icon_image' => self::DROP, + 'host_icon_image_alt' => self::DROP, + 'host_notes' => self::DROP, + 'host_acknowledgement_type' => self::DROP, + 'host_active_checks_enabled_changed' => self::DROP, + 'host_attempt' => self::DROP, + 'host_check_command' => self::DROP, + 'host_check_execution_time' => self::DROP, + 'host_check_latency' => self::DROP, + 'host_check_timeperiod_object_id' => self::DROP, + 'host_check_type' => self::DROP, + 'host_current_check_attempt' => self::DROP, + 'host_current_notification_number' => self::DROP, + 'host_event_handler' => self::DROP, + 'host_event_handler_enabled' => self::DROP, + 'host_event_handler_enabled_changed' => self::DROP, + 'host_failure_prediction_enabled' => self::DROP, + 'host_flap_detection_enabled' => self::DROP, + 'host_flap_detection_enabled_changed' => self::DROP, + 'host_is_reachable' => self::DROP, + 'host_last_notification' => self::DROP, + 'host_max_check_attempts' => self::DROP, + 'host_next_check' => self::DROP, + 'host_next_notification' => self::DROP, + 'host_no_more_notifications' => self::DROP, + 'host_normal_check_interval' => self::DROP, + 'host_notifications_enabled_changed' => self::DROP, + 'host_obsessing' => self::DROP, + 'host_obsessing_changed' => self::DROP, + 'host_passive_checks_enabled_changed' => self::DROP, + 'host_percent_state_change' => self::DROP, + 'host_problem_has_been_acknowledged' => self::DROP, + 'host_process_performance_data' => self::DROP, + 'host_retry_check_interval' => self::DROP, + 'host_scheduled_downtime_depth' => self::DROP, + 'host_status_update_time' => self::DROP, + 'host_unhandled' => self::DROP, + 'object_type' => self::DROP, + 'service_check_interval' => self::DROP, + 'service_icon_image' => self::DROP, + 'service_icon_image_alt' => self::DROP, + 'service_check_execution_time' => self::DROP, + 'service_check_latency' => self::DROP, + 'service_check_timeperiod_object_id' => self::DROP, + 'service_check_type' => self::DROP, + 'service_event_handler' => self::DROP, + 'service_failure_prediction_enabled' => self::DROP, + 'service_is_passive_checked' => self::DROP, + 'service_next_notification' => self::DROP, + 'service_next_update' => function ($filter) { + /** @var Filter\Condition $filter */ + if ($filter->getValue() !== 'now') { + return false; + } + + // Doesn't get dropped because there's a default dashlet using it.. + // Though since this dashlet uses it to check for overdue services we'll + // replace it as next_update is volatile (only in redis up2date) + return Filter::equal('service.state.is_overdue', $filter instanceof Filter\LessThan ? 'y' : 'n'); + }, + 'service_no_more_notifications' => self::DROP, + 'service_normal_check_interval' => self::DROP, + 'service_percent_state_change' => self::DROP, + 'service_problem_has_been_acknowledged' => self::DROP, + 'service_process_performance_data' => self::DROP, + 'service_retry_check_interval' => self::DROP, + 'service_scheduled_downtime_depth' => self::DROP, + 'service_status_update_time' => self::DROP, + 'problems' => self::DROP, + ]; + } + + protected static function servicegridColumns(): array + { + return array_merge( + static::servicesColumns(), + [ + 'problems' => [ + 'problems' => self::USE_EXPR + ] + ] + ); + } + + protected static function multipleServicesColumns(): array + { + return array_merge( + static::servicesColumns(), + [ + 'host' => [ + 'host.name' => self::USE_EXPR + ], + 'service' => [ + 'service.name' => self::USE_EXPR + ] + ] + ); + } + + protected static function serviceColumns(): array + { + return [ + 'host' => [ + 'host.name' => self::USE_EXPR + ], + 'service' => [ + 'name' => self::USE_EXPR + ] + ]; + } + + protected static function hostgroupsColumns(): array + { + return [ + + // Query columns + 'hostgroup_alias' => [ + 'hostgroup.display_name' => self::USE_EXPR + ], + 'hosts_severity' => self::SORT_ONLY, + 'hosts_total' => self::SORT_ONLY, + 'services_total' => self::SORT_ONLY, + + // Filter columns + 'host_contact' => [ + 'host.user.name' => self::USE_EXPR + ], + 'host_contactgroup' => [ + 'host.usergroup.name' => self::USE_EXPR + ] + ]; + } + + protected static function servicegroupsColumns(): array + { + return [ + + // Query columns + 'services_severity' => self::SORT_ONLY, + 'services_total' => self::SORT_ONLY, + 'servicegroup_alias' => [ + 'servicegroup.display_name' => self::USE_EXPR + ], + + // Filter columns + 'host_contact' => [ + 'host.user.name' => self::USE_EXPR + ], + 'host_contactgroup' => [ + 'host.usergroup.name' => self::USE_EXPR + ], + 'service_contact' => [ + 'service.user.name' => self::USE_EXPR + ], + 'service_contactgroup' => [ + 'service.usergroup.name' => self::USE_EXPR + ] + ]; + } + + protected static function contactgroupsColumns(): array + { + return [ + + // Query columns + 'contactgroup_name' => [ + 'usergroup.name' => self::USE_EXPR + ], + 'contactgroup_alias' => [ + 'usergroup.display_name' => self::USE_EXPR + ], + 'contact_count' => self::DROP, + + // Filter columns + 'contactgroup' => [ + 'usergroup.name_ci' => self::USE_EXPR + ] + ]; + } + + protected static function contactsColumns(): array + { + $receivesStateNotifications = function ($state, $type = null) { + return function ($filter) use ($state, $type) { + /** @var Filter\Condition $filter */ + $negate = $filter instanceof Filter\Unequal || $filter instanceof Filter\Unlike; + switch ($filter->getValue()) { + case '0': + $filter = Filter::any( + Filter::equal('user.notifications_enabled', 'n'), + Filter::unequal('user.states', $state) + ); + if ($type !== null) { + $filter->add(Filter::unequal('user.types', $type)); + } + + break; + case '1': + $filter = Filter::all( + Filter::equal('user.notifications_enabled', 'y'), + Filter::equal('user.states', $state) + ); + if ($type !== null) { + $filter->add(Filter::equal('user.types', $type)); + } + + break; + default: + return null; + } + + if ($negate) { + $filter = Filter::none($filter); + } + + return $filter; + }; + }; + + return [ + + // Query columns + 'contact_object_id' => self::DROP, + 'contact_id' => [ + 'user.id' => self::USE_EXPR + ], + 'contact_name' => [ + 'user.name' => self::USE_EXPR + ], + 'contact_alias' => [ + 'user.display_name' => self::USE_EXPR + ], + 'contact_email' => [ + 'user.email' => self::USE_EXPR + ], + 'contact_pager' => [ + 'user.pager' => self::USE_EXPR + ], + 'contact_has_host_notfications' => $receivesStateNotifications(['up', 'down']), + 'contact_has_service_notfications' => $receivesStateNotifications(['ok', 'warning', 'critical', 'unknown']), + 'contact_can_submit_commands' => self::DROP, + 'contact_notify_service_recovery' => $receivesStateNotifications( + ['ok', 'warning', 'critical', 'unknown'], + 'recovery' + ), + 'contact_notify_service_warning' => $receivesStateNotifications('warning'), + 'contact_notify_service_critical' => $receivesStateNotifications('critical'), + 'contact_notify_service_unknown' => $receivesStateNotifications('unknown'), + 'contact_notify_service_flapping' => $receivesStateNotifications( + ['ok', 'warning', 'critical', 'unknown'], + ['flapping_start', 'flapping_end'] + ), + 'contact_notify_service_downtime' => $receivesStateNotifications( + ['ok', 'warning', 'critical', 'unknown'], + ['downtime_start', 'downtime_end', 'downtime_removed'] + ), + 'contact_notify_host_recovery' => $receivesStateNotifications(['up', 'down'], 'recovery'), + 'contact_notify_host_down' => $receivesStateNotifications('down'), + 'contact_notify_host_unreachable' => self::DROP, + 'contact_notify_host_flapping' => $receivesStateNotifications( + ['up', 'down'], + ['flapping_start', 'flapping_end'] + ), + 'contact_notify_host_downtime' => $receivesStateNotifications( + ['up', 'down'], + ['downtime_start', 'downtime_end', 'downtime_removed'] + ), + 'contact_notify_host_timeperiod' => function ($filter) { + /** @var Filter\Condition $filter */ + $filter->setColumn('user.timeperiod.name_ci'); + return Filter::all( + $filter, + Filter::equal('user.states', ['up', 'down']) + ); + }, + 'contact_notify_service_timeperiod' => function ($filter) { + /** @var Filter\Condition $filter */ + $filter->setColumn('user.timeperiod.name_ci'); + return Filter::all( + $filter, + Filter::equal('user.states', ['ok', 'warning', 'critical', 'unknown']) + ); + }, + + // Filter columns + 'contact' => [ + 'user.name_ci' => self::USE_EXPR + ], + 'contactgroup' => [ + 'usergroup.name_ci' => self::USE_EXPR + ], + 'contactgroup_name' => [ + 'usergroup.name' => self::USE_EXPR + ], + 'contactgroup_alias' => [ + 'usergroup.display_name' => self::USE_EXPR + ] + ]; + } + + protected static function commentsColumns(): array + { + return [ + + // Query columns + 'comment_author_name' => [ + 'comment.author' => self::USE_EXPR + ], + 'comment_data' => [ + 'comment.text' => self::USE_EXPR + ], + 'comment_expiration' => [ + 'comment.expire_time' => self::USE_EXPR + ], + 'comment_internal_id' => self::DROP, + 'comment_is_persistent' => [ + 'comment.is_persistent' => self::NO_YES + ], + 'comment_name' => [ + 'comment.name' => self::USE_EXPR + ], + 'comment_timestamp' => [ + 'comment.entry_time' => self::USE_EXPR + ], + 'comment_type' => [ + 'comment.entry_type' => self::LOWER_EXPR + ], + 'host_display_name' => [ + 'host.display_name' => self::USE_EXPR + ], + 'object_type' => [ + 'comment.object_type' => self::LOWER_EXPR + ], + 'service_display_name' => [ + 'service.display_name' => self::USE_EXPR + ], + 'service_host_name' => [ + 'host.name' => self::USE_EXPR + ], + + // Filter columns + 'comment_author' => [ + 'comment.author' => self::USE_EXPR + ] + ]; + } + + protected static function downtimesColumns(): array + { + return [ + + // Query columns + 'downtime_author_name' => [ + 'downtime.author' => self::USE_EXPR + ], + 'downtime_comment' => [ + 'downtime.comment' => self::USE_EXPR + ], + 'downtime_duration' => [ + 'downtime.flexible_duration' => self::USE_EXPR + ], + 'downtime_end' => [ + 'downtime.end_time' => self::USE_EXPR + ], + 'downtime_entry_time' => [ + 'downtime.entry_time' => self::USE_EXPR + ], + 'downtime_internal_id' => self::DROP, + 'downtime_is_fixed' => [ + 'downtime.is_flexible' => array_reverse(self::NO_YES) + ], + 'downtime_is_flexible' => [ + 'downtime.is_flexible' => self::NO_YES + ], + 'downtime_is_in_effect' => [ + 'downtime.is_in_effect' => self::NO_YES + ], + 'downtime_name' => [ + 'downtime.name' => self::USE_EXPR + ], + 'downtime_scheduled_end' => [ + 'downtime.scheduled_end_time' => self::USE_EXPR + ], + 'downtime_scheduled_start' => [ + 'downtime.scheduled_start_time' => self::USE_EXPR + ], + 'downtime_start' => [ + 'downtime.start_time' => self::USE_EXPR + ], + 'host_display_name' => [ + 'host.display_name' => self::USE_EXPR + ], + 'host_state' => [ + 'host.state.soft_state' => self::USE_EXPR + ], + 'object_type' => [ + 'downtime.object_type' => self::LOWER_EXPR + ], + 'service_display_name' => [ + 'service.display_name' => self::USE_EXPR + ], + 'service_host_name' => [ + 'host.name' => self::USE_EXPR + ], + 'service_state' => [ + 'service.state.soft_state' => self::USE_EXPR + ], + + // Filter columns + 'downtime_author' => [ + 'downtime.author' => self::USE_EXPR + ] + ]; + } + + protected static function historyColumns(): array + { + return [ + + // Query columns + 'id' => self::DROP, + 'object_type' => [ + 'history.object_type' => self::LOWER_EXPR + ], + 'timestamp' => [ + 'history.event_time' => self::USE_EXPR + ], + 'state' => [ + 'history.state.soft_state' => self::USE_EXPR + ], + 'output' => [ + 'history.state.output' => self::USE_EXPR + ], + 'type' => function ($filter) { + /** @var Filter\Condition $filter */ + $expr = strtolower($filter->getValue()); + + switch (true) { + // NotificationhistoryQuery + case substr($expr, 0, 13) === 'notification_': + $filter->setColumn('history.notification.type'); + $filter->setValue([ + 'notification_ack' => 'acknowledgement', + 'notification_flapping' => 'flapping_start', + 'notification_flapping_end' => 'flapping_end', + 'notification_dt_start' => 'downtime_start', + 'notification_dt_end' => 'downtime_end', + 'notification_custom' => 'custom', + 'notification_state' => ['problem', 'recovery'] + ][$expr]); + return Filter::all($filter, Filter::equal('history.event_type', 'notification')); + // StatehistoryQuery + case in_array($expr, ['soft_state', 'hard_state'], true): + $filter->setColumn('history.state.state_type'); + $filter->setValue(substr($expr, 0, 4)); + return Filter::all($filter, Filter::equal('history.event_type', 'state_change')); + // DowntimestarthistoryQuery and DowntimeendhistoryQuery + case in_array($expr, ['dt_start', 'dt_end'], true): + $filter->setColumn('history.event_type'); + $filter->setValue('downtime_' . substr($expr, 3)); + return $filter; + // CommenthistoryQuery + case in_array($expr, ['comment', 'ack'], true): + $filter->setColumn('history.comment.entry_type'); + $filter->setValue($expr); + return Filter::all($filter, Filter::equal('history.event_type', 'comment_add')); + // CommentdeletionhistoryQuery + case in_array($expr, ['comment_deleted', 'ack_deleted'], true): + $filter->setColumn('history.comment.entry_type'); + $filter->setValue($expr); + return Filter::all($filter, Filter::equal('history.event_type', 'comment_remove')); + // FlappingstarthistoryQuery and CommenthistoryQuery + case in_array($expr, ['flapping', 'flapping_deleted'], true): + $filter->setColumn('history.event_type'); + return $filter->setValue($expr === 'flapping' ? 'flapping_start' : 'flapping_end'); + } + } + ]; + } + + protected static function notificationHistoryColumns(): array + { + return [ + + // Query columns + 'notification_contact_name' => [ + 'notification_history.user.name' => self::USE_EXPR + ], + 'notification_output' => [ + 'notification_history.text' => self::USE_EXPR + ], + 'notification_reason' => [ + 'notification_history.type' => [ + 0 => ['problem', 'recovery'], + 1 => 'acknowledgement', + 2 => 'flapping_start', + 3 => 'flapping_end', + 5 => 'downtime_start', + 6 => 'downtime_end', + 7 => 'downtime_removed', + 8 => 'custom' // ido schema doc says it's `99`, icinga2 though uses `8` + ] + ], + 'notification_state' => [ + 'notification_history.state' => self::USE_EXPR + ], + 'notification_timestamp' => [ + 'notification_history.send_time' => self::USE_EXPR + ], + 'object_type' => [ + 'notification_history.object_type' => self::LOWER_EXPR + ], + 'service_host_name' => [ + 'host.name' => self::USE_EXPR + ] + ]; + } +} diff --git a/library/Icingadb/Data/CsvResultSet.php b/library/Icingadb/Data/CsvResultSet.php new file mode 100644 index 0000000..746a7e4 --- /dev/null +++ b/library/Icingadb/Data/CsvResultSet.php @@ -0,0 +1,85 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Data; + +use DateTime; +use DateTimeZone; +use Icinga\Module\Icingadb\Redis\VolatileStateResults; +use ipl\Orm\Model; +use ipl\Orm\Query; + +class CsvResultSet extends VolatileStateResults +{ + protected $isCacheDisabled = true; + + /** + * @return array<string, ?string> + */ + public function current(): array + { + return $this->extractKeysAndValues(parent::current()); + } + + protected function formatValue(string $key, $value): ?string + { + if ( + $value + && ( + $key === 'id' + || substr($key, -3) === '_id' + || substr($key, -3) === '.id' + || substr($key, -9) === '_checksum' + || substr($key, -4) === '_bin' + ) + ) { + $value = bin2hex($value); + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } elseif (is_string($value)) { + return '"' . str_replace('"', '""', $value) . '"'; + } elseif (is_array($value)) { + return '"' . implode(',', $value) . '"'; + } elseif ($value instanceof DateTime) { + return $value->setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s.vP'); + } else { + return $value; + } + } + + protected function extractKeysAndValues(Model $model, string $path = ''): array + { + $keysAndValues = []; + foreach ($model as $key => $value) { + $keyPath = ($path ? $path . '.' : '') . $key; + if ($value instanceof Model) { + $keysAndValues += $this->extractKeysAndValues($value, $keyPath); + } else { + $keysAndValues[$keyPath] = $this->formatValue($key, $value); + } + } + + return $keysAndValues; + } + + public static function stream(Query $query): void + { + $query->setResultSetClass(__CLASS__); + + foreach ($query as $i => $keysAndValues) { + if ($i === 0) { + echo implode(',', array_keys($keysAndValues)); + } + + echo "\r\n"; + + echo implode(',', array_values($keysAndValues)); + } + + exit; + } +} diff --git a/library/Icingadb/Data/JsonResultSet.php b/library/Icingadb/Data/JsonResultSet.php new file mode 100644 index 0000000..73cd9ef --- /dev/null +++ b/library/Icingadb/Data/JsonResultSet.php @@ -0,0 +1,80 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Data; + +use DateTime; +use DateTimeZone; +use Icinga\Module\Icingadb\Redis\VolatileStateResults; +use Icinga\Util\Json; +use ipl\Orm\Model; +use ipl\Orm\Query; + +class JsonResultSet extends VolatileStateResults +{ + protected $isCacheDisabled = true; + + /** + * @return array<string, ?string> + */ + public function current(): array + { + return $this->createObject(parent::current()); + } + + protected function formatValue(string $key, $value): ?string + { + if ( + $value + && ( + $key === 'id' + || substr($key, -3) === '_id' + || substr($key, -3) === '.id' + || substr($key, -9) === '_checksum' + || substr($key, -4) === '_bin' + ) + ) { + $value = bin2hex($value); + } + + if ($value instanceof DateTime) { + return $value->setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s.vP'); + } + + return $value; + } + + protected function createObject(Model $model): array + { + $keysAndValues = []; + foreach ($model as $key => $value) { + if ($value instanceof Model) { + $keysAndValues[$key] = $this->createObject($value); + } else { + $keysAndValues[$key] = $this->formatValue($key, $value); + } + } + + return $keysAndValues; + } + + public static function stream(Query $query): void + { + $query->setResultSetClass(__CLASS__); + + echo '['; + foreach ($query as $i => $object) { + if ($i > 0) { + echo ",\n"; + } + + echo Json::sanitize($object); + } + + echo ']'; + + exit; + } +} diff --git a/library/Icingadb/Data/PivotTable.php b/library/Icingadb/Data/PivotTable.php new file mode 100644 index 0000000..1aee20c --- /dev/null +++ b/library/Icingadb/Data/PivotTable.php @@ -0,0 +1,441 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Data; + +use Icinga\Application\Icinga; +use Icinga\Application\Web; +use ipl\Orm\Query; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Stdlib\Filter; + +class PivotTable +{ + const SORT_ASC = 'asc'; + + /** + * The query to fetch as pivot table + * + * @var Query + */ + protected $baseQuery; + + /** + * X-axis pivot column + * + * @var string + */ + protected $xAxisColumn; + + /** + * Y-axis pivot column + * + * @var string + */ + protected $yAxisColumn; + + /** + * The filter being applied on the query for the x-axis + * + * @var Filter\Rule + */ + protected $xAxisFilter; + + /** + * The filter being applied on the query for the y-axis + * + * @var Filter\Rule + */ + protected $yAxisFilter; + + /** + * The query to fetch the leading x-axis rows and their headers + * + * @var Query + */ + protected $xAxisQuery; + + /** + * The query to fetch the leading y-axis rows and their headers + * + * @var Query + */ + protected $yAxisQuery; + + /** + * X-axis header column + * + * @var string|null + */ + protected $xAxisHeader; + + /** + * Y-axis header column + * + * @var string|null + */ + protected $yAxisHeader; + + /** + * Order by column and direction + * + * @var array + */ + protected $order = []; + + /** + * Grid columns as [Alias => Column name] pairs + * + * @var array + */ + protected $gridcols = []; + + /** + * Create a new pivot table + * + * @param Query $query The query to fetch as pivot table + * @param string $xAxisColumn X-axis pivot column + * @param string $yAxisColumn Y-axis pivot column + * @param array $gridcols Grid columns + */ + public function __construct(Query $query, string $xAxisColumn, string $yAxisColumn, array $gridcols) + { + foreach ($query->getOrderBy() as $sort) { + $this->order[$sort[0]] = $sort[1]; + } + + $this->baseQuery = $query->columns($gridcols)->resetOrderBy(); + $this->xAxisColumn = $xAxisColumn; + $this->yAxisColumn = $yAxisColumn; + $this->gridcols = $gridcols; + } + + /** + * Set the filter to apply on the query for the x-axis + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function setXAxisFilter(Filter\Rule $filter = null): self + { + $this->xAxisFilter = $filter; + return $this; + } + + /** + * Set the filter to apply on the query for the y-axis + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function setYAxisFilter(Filter\Rule $filter = null): self + { + $this->yAxisFilter = $filter; + return $this; + } + + /** + * Get the x-axis header + * + * Defaults to {@link $xAxisColumn} in case no x-axis header has been set using {@link setXAxisHeader()} + * + * @return string + */ + public function getXAxisHeader(): string + { + if ($this->xAxisHeader === null && $this->xAxisColumn === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->xAxisHeader !== null ? $this->xAxisHeader : $this->xAxisColumn; + } + + /** + * Set the x-axis header + * + * @param string $xAxisHeader + * + * @return $this + */ + public function setXAxisHeader(string $xAxisHeader): self + { + $this->xAxisHeader = $xAxisHeader; + return $this; + } + + /** + * Get the y-axis header + * + * Defaults to {@link $yAxisColumn} in case no x-axis header has been set using {@link setYAxisHeader()} + * + * @return string + */ + public function getYAxisHeader(): string + { + if ($this->yAxisHeader === null && $this->yAxisColumn === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->yAxisHeader !== null ? $this->yAxisHeader : $this->yAxisColumn; + } + + /** + * Set the y-axis header + * + * @param string $yAxisHeader + * + * @return $this + */ + public function setYAxisHeader(string $yAxisHeader): self + { + $this->yAxisHeader = $yAxisHeader; + return $this; + } + + /** + * Return the value for the given request parameter + * + * @param string $axis The axis for which to return the parameter ('x' or 'y') + * @param string $param The parameter name to return + * @param int $default The default value to return + * + * @return int + */ + protected function getPaginationParameter(string $axis, string $param, int $default = null): int + { + /** @var Web $app */ + $app = Icinga::app(); + + $value = $app->getRequest()->getParam($param, ''); + if (strpos($value, ',') > 0) { + $parts = explode(',', $value, 2); + return intval($parts[$axis === 'x' ? 0 : 1]); + } + + return $default !== null ? $default : 0; + } + + /** + * Query horizontal (x) axis + * + * @return Query + */ + protected function queryXAxis(): Query + { + if ($this->xAxisQuery === null) { + $this->xAxisQuery = clone $this->baseQuery; + $xAxisHeader = $this->getXAxisHeader(); + $table = $this->xAxisQuery->getModel()->getTableName(); + $xCol = explode('.', $this->gridcols[$this->xAxisColumn]); + $columns = [ + $this->xAxisColumn => $this->gridcols[$this->xAxisColumn], + $xAxisHeader => $this->gridcols[$xAxisHeader] + ]; + + // TODO: This shouldn't be required. Refactor this once ipl\Orm\Query has support for group by rules! + if ($xCol[0] !== $table) { + $groupCols = array_unique([ + $this->xAxisColumn => $table . '_' . $this->gridcols[$this->xAxisColumn], + $xAxisHeader => $table . '_' . $this->gridcols[$xAxisHeader] + ]); + } else { + $groupCols = $columns; + } + + $this->xAxisQuery->getSelectBase()->groupBy($groupCols); + + if (count($columns) !== 2) { + $columns[] = $this->gridcols[$xAxisHeader]; + } + + $this->xAxisQuery->columns($columns); + + if ($this->xAxisFilter !== null) { + $this->xAxisQuery->filter($this->xAxisFilter); + } + + $this->xAxisQuery->orderBy( + $this->gridcols[$xAxisHeader], + isset($this->order[$this->gridcols[$xAxisHeader]]) ? + $this->order[$this->gridcols[$xAxisHeader]] : self::SORT_ASC + ); + } + + return $this->xAxisQuery; + } + + /** + * Query vertical (y) axis + * + * @return Query + */ + protected function queryYAxis(): Query + { + if ($this->yAxisQuery === null) { + $this->yAxisQuery = clone $this->baseQuery; + $yAxisHeader = $this->getYAxisHeader(); + $table = $this->yAxisQuery->getModel()->getTableName(); + $columns = [ + $this->yAxisColumn => $this->gridcols[$this->yAxisColumn], + $yAxisHeader => $this->gridcols[$yAxisHeader] + ]; + $yCol = explode('.', $this->gridcols[$this->yAxisColumn]); + + // TODO: This shouldn't be required. Refactor this once ipl\Orm\Query has support for group by rules! + if ($yCol[0] !== $table) { + $groupCols = array_unique([ + $this->yAxisColumn => $table . '_' . $this->gridcols[$this->yAxisColumn], + $yAxisHeader => $table . '_' . $this->gridcols[$yAxisHeader] + ]); + } else { + $groupCols = $columns; + } + + $this->yAxisQuery->getSelectBase()->groupBy($groupCols); + + if (count($columns) !== 2) { + $columns[] = $this->gridcols[$yAxisHeader]; + } + + $this->yAxisQuery->columns($columns); + + if ($this->yAxisFilter !== null) { + $this->yAxisQuery->filter($this->yAxisFilter); + } + + $this->yAxisQuery->orderBy( + $this->gridcols[$yAxisHeader], + isset($this->order[$this->gridcols[$yAxisHeader]]) ? + $this->order[$this->gridcols[$yAxisHeader]] : self::SORT_ASC + ); + } + + return $this->yAxisQuery; + } + + /** + * Return a pagination adapter for the x-axis query + * + * $limit and $page are taken from the current request if not given. + * + * @param int $limit The maximum amount of entries to fetch + * @param int $page The page to set as current one + * + * @return Paginatable + */ + public function paginateXAxis(int $limit = null, int $page = null): Paginatable + { + if ($limit === null || $page === null) { + if ($limit === null) { + $limit = $this->getPaginationParameter('x', 'limit', 20); + } + + if ($page === null) { + $page = $this->getPaginationParameter('x', 'page', 1); + } + } + + $query = $this->queryXAxis(); + + if ($limit !== 0) { + $query->limit($limit); + $query->offset($page > 0 ? ($page - 1) * $limit : 0); + } + + return $query; + } + + /** + * Return a Paginatable for the y-axis query + * + * $limit and $page are taken from the current request if not given. + * + * @param int $limit The maximum amount of entries to fetch + * @param int $page The page to set as current one + * + * @return Paginatable + */ + public function paginateYAxis(int $limit = null, int $page = null): Paginatable + { + if ($limit === null || $page === null) { + if ($limit === null) { + $limit = $this->getPaginationParameter('y', 'limit', 20); + } + + if ($page === null) { + $page = $this->getPaginationParameter('y', 'page', 1); + } + } + $query = $this->queryYAxis(); + + if ($limit !== 0) { + $query->limit($limit); + $query->offset($page > 0 ? ($page - 1) * $limit : 0); + } + + return $query; + } + + /** + * Return the pivot table as an array of pivot data and pivot header + * + * @return array + */ + public function toArray(): array + { + if ( + ($this->xAxisFilter === null && $this->yAxisFilter === null) + || ($this->xAxisFilter !== null && $this->yAxisFilter !== null) + ) { + $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect()); + $xAxisKeys = array_keys($xAxis); + $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect()); + $yAxisKeys = array_keys($yAxis); + } else { + if ($this->xAxisFilter !== null) { + $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect()); + $xAxisKeys = array_keys($xAxis); + $yQuery = $this->queryYAxis(); + $yQuery->filter(Filter::equal($this->gridcols[$this->xAxisColumn], $xAxisKeys)); + $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect()); + $yAxisKeys = array_keys($yAxis); + } else { // $this->yAxisFilter !== null + $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect()); + $yAxisKeys = array_keys($yAxis); + $xQuery = $this->queryXAxis(); + $xQuery->filter(Filter::equal($this->gridcols[$this->yAxisColumn], $yAxisKeys)); + $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect()); + $xAxisKeys = array_keys($yAxis); + } + } + + $pivotData = []; + $pivotHeader = [ + 'cols' => $xAxis, + 'rows' => $yAxis + ]; + + if (! empty($xAxis) && ! empty($yAxis)) { + $this->baseQuery->filter(Filter::equal($this->gridcols[$this->xAxisColumn], $xAxisKeys)); + $this->baseQuery->filter(Filter::equal($this->gridcols[$this->yAxisColumn], $yAxisKeys)); + foreach ($yAxisKeys as $yAxisKey) { + foreach ($xAxisKeys as $xAxisKey) { + $pivotData[$yAxisKey][$xAxisKey] = null; + } + } + + foreach ($this->baseQuery as $row) { + $pivotData[$row->{$this->yAxisColumn}][$row->{$this->xAxisColumn}] = $row; + } + } + + return [$pivotData, $pivotHeader]; + } +} diff --git a/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php b/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php new file mode 100644 index 0000000..9f869a4 --- /dev/null +++ b/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php @@ -0,0 +1,83 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook\ActionsHook; + +use Exception; +use Icinga\Application\Hook; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Module\Icingadb\Hook\Common\HookUtils; +use Icinga\Module\Icingadb\Hook\HostActionsHook; +use Icinga\Module\Icingadb\Hook\ServiceActionsHook; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Orm\Model; +use ipl\Web\Widget\Link; + +use function ipl\Stdlib\get_php_type; + +abstract class ObjectActionsHook +{ + use HookUtils; + + /** + * Load all actions for the given object + * + * @param Host|Service $object + * + * @return HtmlElement + * + * @throws InvalidArgumentException If the given model is not supported + */ + final public static function loadActions(Model $object): HtmlElement + { + switch (true) { + case $object instanceof Host: + $hookName = 'Icingadb\\HostActions'; + break; + case $object instanceof Service: + $hookName = 'Icingadb\\ServiceActions'; + break; + default: + throw new InvalidArgumentException( + sprintf('%s is not a supported object type', get_php_type($object)) + ); + } + + $list = new HtmlElement('ul', Attributes::create(['class' => 'object-detail-actions'])); + + /** @var HostActionsHook|ServiceActionsHook $hook */ + foreach (Hook::all($hookName) as $hook) { + try { + foreach ($hook->getActionsForObject($object) as $link) { + if (! $link instanceof Link) { + continue; + } + + // It may be ValidHtml, but modules shouldn't be able to break our views. + // That's why it needs to be rendered instantly, as any error will then + // be caught here. + $renderedLink = (string) $link; + $moduleName = $hook->getModule()->getName(); + + $list->addHtml(new HtmlElement('li', Attributes::create([ + 'class' => 'icinga-module module-' . $moduleName, + 'data-icinga-module' => $moduleName + ]), HtmlString::create($renderedLink))); + } + } catch (Exception $e) { + Logger::error("Failed to load object actions: %s\n%s", $e, $e->getTraceAsString()); + $list->addHtml(new HtmlElement('li', null, Text::create(IcingaException::describe($e)))); + } + } + + return $list; + } +} diff --git a/library/Icingadb/Hook/Common/HookUtils.php b/library/Icingadb/Hook/Common/HookUtils.php new file mode 100644 index 0000000..8778849 --- /dev/null +++ b/library/Icingadb/Hook/Common/HookUtils.php @@ -0,0 +1,39 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook\Common; + +use Icinga\Application\ClassLoader; +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; + +trait HookUtils +{ + final public function __construct() + { + $this->init(); + } + + /** + * Initialize this hook + * + * Override this in your concrete implementation for any initialization at construction time. + */ + protected function init() + { + } + + /** + * Get the module this hook belongs to + * + * @return Module + */ + final public function getModule(): Module + { + $moduleName = ClassLoader::extractModuleName(static::class); + + return Icinga::app()->getModuleManager() + ->getModule($moduleName); + } +} diff --git a/library/Icingadb/Hook/Common/TotalSlaReportUtils.php b/library/Icingadb/Hook/Common/TotalSlaReportUtils.php new file mode 100644 index 0000000..1006056 --- /dev/null +++ b/library/Icingadb/Hook/Common/TotalSlaReportUtils.php @@ -0,0 +1,56 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook\Common; + +use Icinga\Module\Icingadb\ProvidedHook\Reporting\HostSlaReport; +use Icinga\Module\Reporting\Timerange; +use ipl\Html\Html; +use ipl\Web\Widget\EmptyState; + +use function ipl\I18n\t; + +trait TotalSlaReportUtils +{ + public function getHtml(Timerange $timerange, array $config = null) + { + $data = $this->getData($timerange, $config); + $count = $data->count(); + + if (! $count) { + return new EmptyState(t('No data found.')); + } + + $threshold = (float) ($config['threshold'] ?? static::DEFAULT_THRESHOLD); + + $tableRows = []; + $precision = $config['sla_precision'] ?? static::DEFAULT_REPORT_PRECISION; + + // We only have one average + $average = $data->getAverages()[0]; + + if ($average < $threshold) { + $slaClass = 'nok'; + } else { + $slaClass = 'ok'; + } + + $total = $this instanceof HostSlaReport + ? sprintf(t('Total (%d Hosts)'), $count) + : sprintf(t('Total (%d Services)'), $count); + + $tableRows[] = Html::tag('tr', null, [ + Html::tag('td', ['colspan' => count($data->getDimensions())], $total), + Html::tag('td', ['class' => "sla-column $slaClass"], round($average, $precision)) + ]); + + $table = Html::tag( + 'table', + ['class' => 'common-table sla-table'], + [Html::tag('tbody', null, $tableRows)] + ); + + return $table; + } +} diff --git a/library/Icingadb/Hook/CustomVarRendererHook.php b/library/Icingadb/Hook/CustomVarRendererHook.php new file mode 100644 index 0000000..796de11 --- /dev/null +++ b/library/Icingadb/Hook/CustomVarRendererHook.php @@ -0,0 +1,102 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Closure; +use Exception; +use Icinga\Application\Hook; +use Icinga\Application\Logger; +use Icinga\Module\Icingadb\Hook\Common\HookUtils; +use ipl\Orm\Model; + +abstract class CustomVarRendererHook +{ + use HookUtils; + + /** + * Prefetch the data the hook needs to render custom variables + * + * @param Model $object The object for which they'll be rendered + * + * @return bool Return true if the hook can render variables for the given object, false otherwise + */ + abstract public function prefetchForObject(Model $object): bool; + + /** + * Render the given variable name + * + * @param string $key + * + * @return ?mixed + */ + abstract public function renderCustomVarKey(string $key); + + /** + * Render the given variable value + * + * @param string $key + * @param mixed $value + * + * @return ?mixed + */ + abstract public function renderCustomVarValue(string $key, $value); + + /** + * Return a group name for the given variable name + * + * @param string $key + * + * @return ?string + */ + abstract public function identifyCustomVarGroup(string $key): ?string; + + /** + * Prepare available hooks to render custom variables of the given object + * + * @param Model $object + * + * @return Closure A callback ($key, $value) which returns an array [$newKey, $newValue, $group] + */ + final public static function prepareForObject(Model $object): Closure + { + $hooks = []; + foreach (Hook::all('Icingadb/CustomVarRenderer') as $hook) { + /** @var self $hook */ + try { + if ($hook->prefetchForObject($object)) { + $hooks[] = $hook; + } + } catch (Exception $e) { + Logger::error('Failed to load hook %s:', get_class($hook), $e); + } + } + + return function (string $key, $value) use ($hooks) { + $newKey = $key; + $newValue = $value; + $group = null; + foreach ($hooks as $hook) { + /** @var self $hook */ + + try { + $renderedKey = $hook->renderCustomVarKey($key); + $renderedValue = $hook->renderCustomVarValue($key, $value); + $group = $hook->identifyCustomVarGroup($key); + } catch (Exception $e) { + Logger::error('Failed to use hook %s:', get_class($hook), $e); + continue; + } + + if ($renderedKey !== null || $renderedValue !== null) { + $newKey = $renderedKey ?? $key; + $newValue = $renderedValue ?? $value; + break; + } + } + + return [$newKey, $newValue, $group]; + }; + } +} diff --git a/library/Icingadb/Hook/EventDetailExtensionHook.php b/library/Icingadb/Hook/EventDetailExtensionHook.php new file mode 100644 index 0000000..c348a0c --- /dev/null +++ b/library/Icingadb/Hook/EventDetailExtensionHook.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\History; +use ipl\Html\ValidHtml; + +abstract class EventDetailExtensionHook extends ObjectDetailExtensionHook +{ + /** + * Assemble and return an HTML representation of the given event + * + * @param History $event + * + * @return ValidHtml + */ + abstract public function getHtmlForObject(History $event): ValidHtml; +} diff --git a/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php new file mode 100644 index 0000000..dfefdcd --- /dev/null +++ b/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php @@ -0,0 +1,146 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook\ExtensionHook; + +use Icinga\Module\Icingadb\Hook\Common\HookUtils; + +abstract class BaseExtensionHook +{ + use HookUtils; + + /** @var int Used as default return value for {@see BaseExtensionHook::getLocation()} */ + const IDENTIFY_LOCATION_BY_SECTION = -1; + + /** @var string Output section, right at the top */ + const OUTPUT_SECTION = 'output'; + + /** @var string Graph section, below output */ + const GRAPH_SECTION = 'graph'; + + /** @var string Detail section, below graphs */ + const DETAIL_SECTION = 'detail'; + + /** @var string Action section, below action and note urls */ + const ACTION_SECTION = 'action'; + + /** @var string Problem section, below comments and downtimes */ + const PROBLEM_SECTION = 'problem'; + + /** @var string Related section, below groups and notification recipients */ + const RELATED_SECTION = 'related'; + + /** @var string State section, below check statistics and performance data */ + const STATE_SECTION = 'state'; + + /** @var string Config section, below custom variables and feature toggles */ + const CONFIG_SECTION = 'config'; + + /** + * Base locations for all known sections + * + * @var array<string, int> + */ + const BASE_LOCATIONS = [ + self::OUTPUT_SECTION => 1000, + self::GRAPH_SECTION => 1100, + self::DETAIL_SECTION => 1200, + self::ACTION_SECTION => 1300, + self::PROBLEM_SECTION => 1400, + self::RELATED_SECTION => 1500, + self::STATE_SECTION => 1600, + self::CONFIG_SECTION => 1700 + ]; + + /** @var int This hook's location */ + private $location = self::IDENTIFY_LOCATION_BY_SECTION; + + /** @var string This hook's section */ + private $section = self::DETAIL_SECTION; + + /** + * Set this hook's location + * + * Note that setting the location explicitly may override other widgets using the same location. But beware that + * this applies to this hook's widget as well. + * + * Also, while the sections are guaranteed to always refer to the same general location, this guarantee is lost + * when setting a location explicitly. The core and base locations may change at any time and any explicitly set + * location will **not** adjust accordingly. + * + * @param int $location + * + * @return void + */ + final public function setLocation(int $location) + { + $this->location = $location; + } + + /** + * Get this hook's location + * + * @return int + */ + final public function getLocation(): int + { + return $this->location; + } + + /** + * Set this hook's section + * + * Sections are used to place widgets loosely in a general location. Using e.g. the `state` section this hook's + * widget will always appear after the check statistics and performance data widgets. + * + * @param string $section + * + * @return void + */ + final public function setSection(string $section) + { + $this->section = $section; + } + + /** + * Get this hook's section + * + * @return string + */ + final public function getSection(): string + { + return $this->section; + } + + /** + * Union both arrays and sort the result by key + * + * @param array $coreElements + * @param array $extensions + * + * @return array + */ + final public static function injectExtensions(array $coreElements, array $extensions): array + { + $extensions += $coreElements; + + uksort($extensions, function ($a, $b) { + if ($a < 1000 && $b >= 1000) { + $b -= 1000; + if (abs($a - $b) < 10 && abs($a % 100 - $b % 100) < 10) { + return -1; + } + } elseif ($b < 1000 && $a >= 1000) { + $a -= 1000; + if (abs($a - $b) < 10 && abs($a % 100 - $b % 100) < 10) { + return 1; + } + } + + return $a < $b ? -1 : ($a > $b ? 1 : 0); + }); + + return $extensions; + } +} diff --git a/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php new file mode 100644 index 0000000..7f880e8 --- /dev/null +++ b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php @@ -0,0 +1,121 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook\ExtensionHook; + +use Exception; +use Icinga\Application\Hook; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Module\Icingadb\Hook\EventDetailExtensionHook; +use Icinga\Module\Icingadb\Hook\HostDetailExtensionHook; +use Icinga\Module\Icingadb\Hook\ServiceDetailExtensionHook; +use Icinga\Module\Icingadb\Hook\UserDetailExtensionHook; +use Icinga\Module\Icingadb\Hook\UsergroupDetailExtensionHook; +use Icinga\Module\Icingadb\Model\History; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Module\Icingadb\Model\User; +use Icinga\Module\Icingadb\Model\Usergroup; +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Html\ValidHtml; +use ipl\Orm\Model; + +use function ipl\Stdlib\get_php_type; + +abstract class ObjectDetailExtensionHook extends BaseExtensionHook +{ + /** + * Load all extensions for the given object + * + * @param Host|Service|User|Usergroup|History $object + * + * @return array<int, ValidHtml> + * + * @throws InvalidArgumentException If the given model is not supported + */ + final public static function loadExtensions(Model $object): array + { + switch (true) { + case $object instanceof Host: + $hookName = 'Icingadb\\HostDetailExtension'; + break; + case $object instanceof Service: + $hookName = 'Icingadb\\ServiceDetailExtension'; + break; + case $object instanceof User: + $hookName = 'Icingadb\\UserDetailExtension'; + break; + case $object instanceof Usergroup: + $hookName = 'Icingadb\\UsergroupDetailExtension'; + break; + case $object instanceof History: + $hookName = 'Icingadb\\EventDetailExtension'; + break; + default: + throw new InvalidArgumentException( + sprintf('%s is not a supported object type', get_php_type($object)) + ); + } + + $extensions = []; + $lastUsedLocations = []; + + /** + * @var $hook HostDetailExtensionHook + * @var $hook ServiceDetailExtensionHook + * @var $hook UserDetailExtensionHook + * @var $hook UsergroupDetailExtensionHook + * @var $hook EventDetailExtensionHook + */ + foreach (Hook::all($hookName) as $hook) { + $location = $hook->getLocation(); + if ($location < 0) { + $location = null; + } + + if ($location === null) { + $section = $hook->getSection(); + if (! isset(self::BASE_LOCATIONS[$section])) { + Logger::error('Detail extension %s is using an invalid section: %s', get_class($hook), $section); + $section = self::DETAIL_SECTION; + } + + if (isset($lastUsedLocations[$section])) { + $location = ++$lastUsedLocations[$section]; + } else { + $location = self::BASE_LOCATIONS[$section]; + $lastUsedLocations[$section] = $location; + } + } + + try { + // It may be ValidHtml, but modules shouldn't be able to break our views. + // That's why it needs to be rendered instantly, as any error will then + // be caught here. + $extension = (string) $hook->getHtmlForObject(clone $object); + + $moduleName = $hook->getModule()->getName(); + + $extensions[$location] = new HtmlElement( + 'div', + Attributes::create([ + 'class' => 'icinga-module module-' . $moduleName, + 'data-icinga-module' => $moduleName + ]), + HtmlString::create($extension) + ); + } catch (Exception $e) { + Logger::error("Failed to load detail extension: %s\n%s", $e, $e->getTraceAsString()); + $extensions[$location] = Text::create(IcingaException::describe($e)); + } + } + + return $extensions; + } +} diff --git a/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php new file mode 100644 index 0000000..cc49667 --- /dev/null +++ b/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php @@ -0,0 +1,101 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook\ExtensionHook; + +use Exception; +use Icinga\Application\Hook; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Module\Icingadb\Hook\HostsDetailExtensionHook; +use Icinga\Module\Icingadb\Hook\ServicesDetailExtensionHook; +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Html\ValidHtml; +use ipl\Orm\Query; +use ipl\Stdlib\BaseFilter; +use ipl\Stdlib\Filter; + +abstract class ObjectsDetailExtensionHook extends BaseExtensionHook +{ + use BaseFilter; + + /** + * Load all extensions for the given objects + * + * @param string $objectType + * @param Query $query + * @param Filter\Rule $baseFilter + * + * @return array<int, ValidHtml> + * + * @throws InvalidArgumentException If the given object type is not supported + */ + final public static function loadExtensions(string $objectType, Query $query, Filter\Rule $baseFilter): array + { + switch ($objectType) { + case 'host': + $hookName = 'Icingadb\\HostsDetailExtension'; + break; + case 'service': + $hookName = 'Icingadb\\ServicesDetailExtension'; + break; + default: + throw new InvalidArgumentException( + sprintf('%s is not a supported object type', $objectType) + ); + } + + $extensions = []; + $lastUsedLocations = []; + /** @var HostsDetailExtensionHook|ServicesDetailExtensionHook $hook */ + foreach (Hook::all($hookName) as $hook) { + $location = $hook->getLocation(); + if ($location < 0) { + $location = null; + } + + if ($location === null) { + $section = $hook->getSection(); + if (! isset(self::BASE_LOCATIONS[$section])) { + Logger::error('Detail extension %s is using an invalid section: %s', get_class($hook), $section); + $section = self::DETAIL_SECTION; + } + + if (isset($lastUsedLocations[$section])) { + $location = ++$lastUsedLocations[$section]; + } else { + $location = self::BASE_LOCATIONS[$section]; + $lastUsedLocations[$section] = $location; + } + } + + try { + // It may be ValidHtml, but modules shouldn't be able to break our views. + // That's why it needs to be rendered instantly, as any error will then + // be caught here. + $extension = (string) $hook->setBaseFilter($baseFilter)->getHtmlForObjects(clone $query); + + $moduleName = $hook->getModule()->getName(); + + $extensions[$location] = new HtmlElement( + 'div', + Attributes::create([ + 'class' => 'icinga-module module-' . $moduleName, + 'data-icinga-module' => $moduleName + ]), + HtmlString::create($extension) + ); + } catch (Exception $e) { + Logger::error("Failed to load details extension: %s\n%s", $e, $e->getTraceAsString()); + $extensions[$location] = Text::create(IcingaException::describe($e)); + } + } + + return $extensions; + } +} diff --git a/library/Icingadb/Hook/HostActionsHook.php b/library/Icingadb/Hook/HostActionsHook.php new file mode 100644 index 0000000..73c58f4 --- /dev/null +++ b/library/Icingadb/Hook/HostActionsHook.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook; +use Icinga\Module\Icingadb\Model\Host; +use ipl\Web\Widget\Link; + +abstract class HostActionsHook extends ObjectActionsHook +{ + /** + * Assemble and return a list of HTML anchors for the given host + * + * @param Host $host + * + * @return Link[] + */ + abstract public function getActionsForObject(Host $host): array; +} diff --git a/library/Icingadb/Hook/HostDetailExtensionHook.php b/library/Icingadb/Hook/HostDetailExtensionHook.php new file mode 100644 index 0000000..a6e9ab0 --- /dev/null +++ b/library/Icingadb/Hook/HostDetailExtensionHook.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Host; +use ipl\Html\ValidHtml; + +abstract class HostDetailExtensionHook extends ObjectDetailExtensionHook +{ + /** + * Assemble and return an HTML representation of the given host + * + * @param Host $host + * + * @return ValidHtml + */ + abstract public function getHtmlForObject(Host $host): ValidHtml; +} diff --git a/library/Icingadb/Hook/HostsDetailExtensionHook.php b/library/Icingadb/Hook/HostsDetailExtensionHook.php new file mode 100644 index 0000000..79c091e --- /dev/null +++ b/library/Icingadb/Hook/HostsDetailExtensionHook.php @@ -0,0 +1,28 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Host; +use ipl\Html\ValidHtml; +use ipl\Orm\Query; + +abstract class HostsDetailExtensionHook extends ObjectsDetailExtensionHook +{ + /** + * Assemble and return an HTML representation of the given hosts + * + * The given query is already pre-filtered with the user's custom filter and restrictions. The base filter does + * only contain the user's custom filter, use this for e.g. subsidiary links. + * + * The query is also limited by default, use `$hosts->limit(null)` to clear that. But beware that this may yield + * a huge result set in case of a bulk selection. + * + * @param Query<Host> $hosts + * + * @return ValidHtml + */ + abstract public function getHtmlForObjects(Query $hosts): ValidHtml; +} diff --git a/library/Icingadb/Hook/IcingadbSupportHook.php b/library/Icingadb/Hook/IcingadbSupportHook.php new file mode 100644 index 0000000..96cdd19 --- /dev/null +++ b/library/Icingadb/Hook/IcingadbSupportHook.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Module\Icingadb\Hook\Common\HookUtils; +use Icinga\Web\Session; + +abstract class IcingadbSupportHook +{ + use HookUtils; + + /** @var string key name of preference */ + const PREFERENCE_NAME = 'icingadb.as_backend'; + + /** + * Return whether your module supports IcingaDB or not + * + * @return bool + */ + public function supportsIcingaDb(): bool + { + return true; + } + + /** + * Whether icingadb is set as the preferred backend in preferences + * + * @return bool Return true if icingadb is set as backend, false otherwise + */ + final public static function isIcingaDbSetAsPreferredBackend(): bool + { + return (bool) Session::getSession() + ->getNamespace('icingadb') + ->get(self::PREFERENCE_NAME, false); + } + + /** + * Whether to use icingadb as the backend + * + * @return bool Returns true if monitoring module is accessible or icingadb is selected as backend, false otherwise. + */ + final public static function useIcingaDbAsBackend(): bool + { + return ! Icinga::app()->getModuleManager()->hasEnabled('monitoring') + || ! Auth::getInstance()->hasPermission('module/monitoring') + || self::isIcingaDbSetAsPreferredBackend(); + } +} diff --git a/library/Icingadb/Hook/PluginOutputHook.php b/library/Icingadb/Hook/PluginOutputHook.php new file mode 100644 index 0000000..7c744ee --- /dev/null +++ b/library/Icingadb/Hook/PluginOutputHook.php @@ -0,0 +1,63 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Exception; +use Icinga\Application\Hook; +use Icinga\Application\Logger; +use Icinga\Module\Icingadb\Hook\Common\HookUtils; + +abstract class PluginOutputHook +{ + use HookUtils; + + /** + * Return whether the given command is supported or not + * + * @param string $commandName + * + * @return bool + */ + abstract public function isSupportedCommand(string $commandName): bool; + + /** + * Process the given plugin output based on the specified check command + * + * Try to process the output as efficient and fast as possible. + * Especially list view performance may suffer otherwise. + * + * @param string $output A host's or service's output + * @param string $commandName The name of the checkcommand that produced the output + * @param bool $enrichOutput Whether macros or other markup should be processed + * + * @return string + */ + abstract public function render(string $output, string $commandName, bool $enrichOutput): string; + + /** + * Let all hooks process the given plugin output based on the specified check command + * + * @param string $output + * @param string $commandName + * @param bool $enrichOutput + * + * @return string + */ + final public static function processOutput(string $output, string $commandName, bool $enrichOutput): string + { + foreach (Hook::all('Icingadb\\PluginOutput') as $hook) { + /** @var self $hook */ + try { + if ($hook->isSupportedCommand($commandName)) { + $output = $hook->render($output, $commandName, $enrichOutput); + } + } catch (Exception $e) { + Logger::error("Unable to process plugin output: %s\n%s", $e, $e->getTraceAsString()); + } + } + + return $output; + } +} diff --git a/library/Icingadb/Hook/ServiceActionsHook.php b/library/Icingadb/Hook/ServiceActionsHook.php new file mode 100644 index 0000000..988cdb6 --- /dev/null +++ b/library/Icingadb/Hook/ServiceActionsHook.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Web\Widget\Link; + +abstract class ServiceActionsHook extends ObjectActionsHook +{ + /** + * Assemble and return a list of HTML anchors for the given service + * + * @param Service $service + * + * @return Link[] + */ + abstract public function getActionsForObject(Service $service): array; +} diff --git a/library/Icingadb/Hook/ServiceDetailExtensionHook.php b/library/Icingadb/Hook/ServiceDetailExtensionHook.php new file mode 100644 index 0000000..5344620 --- /dev/null +++ b/library/Icingadb/Hook/ServiceDetailExtensionHook.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Html\ValidHtml; + +abstract class ServiceDetailExtensionHook extends ObjectDetailExtensionHook +{ + /** + * Assemble and return an HTML representation of the given service + * + * @param Service $service + * + * @return ValidHtml + */ + abstract public function getHtmlForObject(Service $service): ValidHtml; +} diff --git a/library/Icingadb/Hook/ServicesDetailExtensionHook.php b/library/Icingadb/Hook/ServicesDetailExtensionHook.php new file mode 100644 index 0000000..35ba8d3 --- /dev/null +++ b/library/Icingadb/Hook/ServicesDetailExtensionHook.php @@ -0,0 +1,28 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Html\ValidHtml; +use ipl\Orm\Query; + +abstract class ServicesDetailExtensionHook extends ObjectsDetailExtensionHook +{ + /** + * Assemble and return an HTML representation of the given services + * + * The given query is already pre-filtered with the user's custom filter and restrictions. The base filter does + * only contain the user's custom filter, use this for e.g. subsidiary links. + * + * The query is also limited by default, use `$hosts->limit(null)` to clear that. But beware that this may yield + * a huge result set in case of a bulk selection. + * + * @param Query<Service> $services + * + * @return ValidHtml + */ + abstract public function getHtmlForObjects(Query $services): ValidHtml; +} diff --git a/library/Icingadb/Hook/TabHook.php b/library/Icingadb/Hook/TabHook.php new file mode 100644 index 0000000..0c5b676 --- /dev/null +++ b/library/Icingadb/Hook/TabHook.php @@ -0,0 +1,82 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Hook\Common\HookUtils; +use ipl\Html\ValidHtml; +use ipl\Orm\Model; + +abstract class TabHook +{ + use Auth; + use Database; + use HookUtils; + + /** + * Get the tab's name + * + * The name is used to identify this hook later on. It must be unique. + * Multiple words in the name should be separated by dashes. (-) + * + * @return string + */ + abstract public function getName(): string; + + /** + * Get the tab's label + * + * The label is shown on the tab and in the browser's title. + * + * @return string + */ + abstract public function getLabel(): string; + + /** + * Get tab content for the given object + * + * @param Model $object + * + * @return ValidHtml[] + */ + abstract public function getContent(Model $object): array; + + /** + * Get tab controls for the given object + * + * @param Model $object + * + * @return ValidHtml[] + */ + public function getControls(Model $object): array + { + return []; + } + + /** + * Get tab footer for the given object + * + * @param Model $object + * + * @return ValidHtml[] + */ + public function getFooter(Model $object): array + { + return []; + } + + /** + * Get whether this tab should be shown + * + * @param Model $object + * + * @return bool + */ + public function shouldBeShown(Model $object): bool + { + return true; + } +} diff --git a/library/Icingadb/Hook/TabHook/HookActions.php b/library/Icingadb/Hook/TabHook/HookActions.php new file mode 100644 index 0000000..d2801a5 --- /dev/null +++ b/library/Icingadb/Hook/TabHook/HookActions.php @@ -0,0 +1,148 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook\TabHook; + +use Exception; +use Generator; +use Icinga\Application\Hook; +use Icinga\Application\Logger; +use Icinga\Module\Icingadb\Hook\TabHook; +use ipl\Html\ValidHtml; +use ipl\Orm\Model; +use ipl\Stdlib\Str; + +/** + * Trait HookActions + */ +trait HookActions +{ + /** @var Model The object to load tabs for */ + protected $objectToLoadTabsFor; + + /** @var TabHook[] Loaded tab hooks */ + protected $tabHooks; + + /** + * Get default control elements + * + * @return ValidHtml[] + */ + abstract protected function getDefaultTabControls(): array; + + public function __call($methodName, $args) + { + if (substr($methodName, -6) === 'Action') { + $hookName = substr($methodName, 0, -6); + + $hooks = $this->loadTabHooks(); + if (isset($hooks[$hookName])) { + $this->showTabHook($hooks[$hookName]); + return; + } + } + + parent::__call($methodName, $args); + } + + /** + * Register the object for which to load additional tabs + * + * @param Model $object + * + * @return void + */ + protected function loadTabsForObject(Model $object) + { + $this->objectToLoadTabsFor = $object; + } + + /** + * Load tab hooks + * + * @return array<string, TabHook> + */ + protected function loadTabHooks(): array + { + if ($this->objectToLoadTabsFor === null) { + return []; + } elseif ($this->tabHooks !== null) { + return $this->tabHooks; + } + + $this->tabHooks = []; + foreach (Hook::all('Icingadb\\Tab') as $hook) { + /** @var TabHook $hook */ + try { + if ($hook->shouldBeShown($this->objectToLoadTabsFor)) { + $this->tabHooks[Str::camel($hook->getName())] = $hook; + } + } catch (Exception $e) { + Logger::error("Failed to load tab hook: %s\n%s", $e, $e->getTraceAsString()); + } + } + + return $this->tabHooks; + } + + /** + * Load additional tabs + * + * @return Generator<string, array{label: string, url: string}> + */ + protected function loadAdditionalTabs(): Generator + { + foreach ($this->loadTabHooks() as $hook) { + yield $hook->getName() => [ + 'label' => $hook->getLabel(), + 'url' => 'icingadb/' . $this->getRequest()->getControllerName() . '/' . $hook->getName() + ]; + } + } + + /** + * Render the given tab hook + * + * @param TabHook $hook + * + * @return void + */ + protected function showTabHook(TabHook $hook) + { + $moduleName = $hook->getModule()->getName(); + + foreach ($hook->getControls($this->objectToLoadTabsFor) as $control) { + $this->addControl($control); + } + + if (! empty($this->controls->getContent())) { + $this->controls->addAttributes([ + 'class' => ['icinga-module', 'module-' . $moduleName], + 'data-icinga-module' => $moduleName + ]); + } else { + foreach ($this->getDefaultTabControls() as $control) { + $this->addControl($control); + } + } + + foreach ($hook->getContent($this->objectToLoadTabsFor) as $content) { + $this->addContent($content); + } + + $this->content->addAttributes([ + 'class' => ['icinga-module', 'module-' . $moduleName], + 'data-icinga-module' => $moduleName + ]); + + foreach ($hook->getFooter($this->objectToLoadTabsFor) as $footer) { + $this->addFooter($footer); + } + + $this->footer->addAttributes([ + 'class' => ['icinga-module', 'module-' . $moduleName], + 'data-icinga-module' => $moduleName + ]); + } +} diff --git a/library/Icingadb/Hook/UserDetailExtensionHook.php b/library/Icingadb/Hook/UserDetailExtensionHook.php new file mode 100644 index 0000000..bb1bf7e --- /dev/null +++ b/library/Icingadb/Hook/UserDetailExtensionHook.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\User; +use ipl\Html\ValidHtml; + +abstract class UserDetailExtensionHook extends ObjectDetailExtensionHook +{ + /** + * Assemble and return an HTML representation of the given user + * + * @param User $user + * + * @return ValidHtml + */ + abstract public function getHtmlForObject(User $user): ValidHtml; +} diff --git a/library/Icingadb/Hook/UsergroupDetailExtensionHook.php b/library/Icingadb/Hook/UsergroupDetailExtensionHook.php new file mode 100644 index 0000000..da2264d --- /dev/null +++ b/library/Icingadb/Hook/UsergroupDetailExtensionHook.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Hook; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Usergroup; +use ipl\Html\ValidHtml; + +abstract class UsergroupDetailExtensionHook extends ObjectDetailExtensionHook +{ + /** + * Assemble and return an HTML representation of the given usergroup + * + * @param Usergroup $usergroup + * + * @return ValidHtml + */ + abstract public function getHtmlForObject(Usergroup $usergroup): ValidHtml; +} diff --git a/library/Icingadb/Model/AcknowledgementHistory.php b/library/Icingadb/Model/AcknowledgementHistory.php new file mode 100644 index 0000000..549d2ff --- /dev/null +++ b/library/Icingadb/Model/AcknowledgementHistory.php @@ -0,0 +1,102 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * Model for table `acknowledgement_history` + * + * Please note that using this model will fetch history entries for decommissioned services. To avoid this, the query + * needs a `acknowledgement_history.service_id IS NULL OR acknowledgement_history_service.id IS NOT NULL` where. + */ +class AcknowledgementHistory extends Model +{ + public function getTableName() + { + return 'acknowledgement_history'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'object_type', + 'host_id', + 'service_id', + 'set_time', + 'clear_time', + 'author', + 'cleared_by', + 'comment', + 'expire_time', + 'is_sticky', + 'is_persistent' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'endpoint_id' => t('Endpoint Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'set_time' => t('Acknowledgement Set Time'), + 'clear_time' => t('Acknowledgement Clear Time'), + 'author' => t('Acknowledgement Author'), + 'cleared_by' => t('Acknowledgement Cleared By'), + 'comment' => t('Acknowledgement Comment'), + 'expire_time' => t('Acknowledgement Expire Time'), + 'is_sticky' => t('Acknowledgement Is Sticky'), + 'is_persistent' => t('Acknowledgement Is Persistent') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'is_sticky', + 'is_persistent' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'set_time', + 'clear_time', + 'expire_time' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id', + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('endpoint', Endpoint::class); + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('history', History::class) + ->setCandidateKey('id') + ->setForeignKey('acknowledgement_history_id'); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/ActionUrl.php b/library/Icingadb/Model/ActionUrl.php new file mode 100644 index 0000000..e0b092e --- /dev/null +++ b/library/Icingadb/Model/ActionUrl.php @@ -0,0 +1,62 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ActionAndNoteUrl; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class ActionUrl extends Model +{ + public function getTableName() + { + return 'action_url'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'action_url', + 'environment_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'action_url' => t('Action Url'), + 'environment_id' => t('Environment Id') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ActionAndNoteUrl(['action_url'])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + + $relations->hasMany('host', Host::class) + ->setCandidateKey('id') + ->setForeignKey('action_url_id'); + $relations->hasMany('service', Service::class) + ->setCandidateKey('id') + ->setForeignKey('action_url_id'); + } +} diff --git a/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php b/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php new file mode 100644 index 0000000..e8f6799 --- /dev/null +++ b/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model\Behavior; + +use ipl\Orm\Contract\PropertyBehavior; + +class ActionAndNoteUrl extends PropertyBehavior +{ + public function fromDb($value, $key, $_) + { + if (empty($value)) { + return []; + } + + $links = []; + if (strpos($value, "' ") === false) { + $links[] = $value; + } else { + foreach (explode("' ", $value) as $url) { + $url = strpos($url, "'") === 0 ? substr($url, 1) : $url; + $url = strpos($url, "'") === strlen($url) - 1 ? substr($url, 0, strlen($url) - 1) : $url; + $links[] = $url; + } + } + + return $links; + } + + public function toDb($value, $key, $_) + { + if (empty($value) || ! is_array($value)) { + return $value; + } + + if (count($value) === 1) { + return $value[0]; + } + + $links = ''; + foreach ($value as $url) { + if (! empty($links)) { + $links .= ' '; + } + + $links .= "'$url'"; + } + + return $links; + } +} diff --git a/library/Icingadb/Model/Behavior/Bitmask.php b/library/Icingadb/Model/Behavior/Bitmask.php new file mode 100644 index 0000000..f8d91f6 --- /dev/null +++ b/library/Icingadb/Model/Behavior/Bitmask.php @@ -0,0 +1,83 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model\Behavior; + +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Stdlib\Filter\Condition; + +/** + * Class Bitmask + * + * @method void __construct(array $properties) Pass property names as keys and their bitmap ([value => bit]) as value + */ +class Bitmask extends PropertyBehavior implements RewriteFilterBehavior +{ + public function fromDb($bits, $key, $context) + { + $values = []; + foreach ($context as $value => $bit) { + if ($bits & $bit) { + $values[] = $value; + } + } + + return $values; + } + + public function toDb($value, $key, $context) + { + if (! is_array($value)) { + if (is_int($value) || ctype_digit($value)) { + return $value; + } + + return isset($context[$value]) ? $context[$value] : -1; + } + + $bits = []; + $allBits = 0; + foreach ($value as $v) { + if (isset($context[$v])) { + $bits[] = $context[$v]; + $allBits |= $context[$v]; + } elseif (is_int($v) || ctype_digit($v)) { + $bits[] = $v; + $allBits |= $v; + } + } + + $bits[] = $allBits; + return $bits; + } + + public function rewriteCondition(Condition $condition, $relation = null) + { + $column = $condition->metaData()->get('columnName'); + if (! isset($this->properties[$column])) { + return; + } + + $values = $condition->getValue(); + if (! is_array($values)) { + if (is_int($values) || ctype_digit($values)) { + return; + } + + $values = [$values]; + } + + $bits = 0; + foreach ($values as $value) { + if (isset($this->properties[$column][$value])) { + $bits |= $this->properties[$column][$value]; + } elseif (is_int($value) || ctype_digit($value)) { + $bits |= $value; + } + } + + $condition->setColumn(sprintf('%s & %s', $condition->getColumn(), $bits)); + } +} diff --git a/library/Icingadb/Model/Behavior/BoolCast.php b/library/Icingadb/Model/Behavior/BoolCast.php new file mode 100644 index 0000000..8ab01ae --- /dev/null +++ b/library/Icingadb/Model/Behavior/BoolCast.php @@ -0,0 +1,31 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model\Behavior; + +use ipl\Orm\Contract\PropertyBehavior; + +class BoolCast extends PropertyBehavior +{ + public function fromDb($value, $key, $_) + { + switch ((string) $value) { + case 'y': + return true; + case 'n': + return false; + default: + return $value; + } + } + + public function toDb($value, $key, $_) + { + if (is_string($value)) { + return $value; + } + + return $value ? 'y' : 'n'; + } +} diff --git a/library/Icingadb/Model/Behavior/FlattenedObjectVars.php b/library/Icingadb/Model/Behavior/FlattenedObjectVars.php new file mode 100644 index 0000000..b1c308a --- /dev/null +++ b/library/Icingadb/Model/Behavior/FlattenedObjectVars.php @@ -0,0 +1,77 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model\Behavior; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Model\CustomvarFlat; +use ipl\Orm\AliasedExpression; +use ipl\Orm\ColumnDefinition; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Contract\RewriteColumnBehavior; +use ipl\Orm\Query; +use ipl\Stdlib\Filter; + +class FlattenedObjectVars implements RewriteColumnBehavior, QueryAwareBehavior +{ + use Auth; + + /** @var Query */ + protected $query; + + public function setQuery(Query $query) + { + $this->query = $query; + + return $this; + } + + public function rewriteCondition(Filter\Condition $condition, $relation = null) + { + $column = $condition->metaData()->get('columnName'); + if ($column !== null) { + $relation = substr($relation, 0, -5) . 'customvar_flat.'; + $nameFilter = Filter::like($relation . 'flatname', $column); + $class = get_class($condition); + $valueFilter = new $class($relation . 'flatvalue', $condition->getValue()); + + return Filter::all($nameFilter, $valueFilter); + } + } + + public function rewriteColumn($column, $relation = null) + { + $subQuery = $this->query->createSubQuery(new CustomvarFlat(), $relation) + ->limit(1) + ->columns('flatvalue') + ->filter(Filter::equal('flatname', $column)); + + $this->applyRestrictions($subQuery); + + $alias = $this->query->getDb()->quoteIdentifier([str_replace('.', '_', $relation) . "_$column"]); + + list($select, $values) = $this->query->getDb()->getQueryBuilder()->assembleSelect($subQuery->assembleSelect()); + return new AliasedExpression($alias, "($select)", null, ...$values); + } + + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void + { + $parts = explode('.', substr($relation, 0, -5)); + $objectType = array_pop($parts); + + $name = $def->getName(); + if (substr($name, -3) === '[*]') { + // The suggestions also hide this from the label, so should this + $name = substr($name, 0, -3); + } + + // Programmatically translated since the full definition is available in class ObjectSuggestions + $def->setLabel(sprintf(t(ucfirst($objectType) . ' %s', '..<customvar-name>'), $name)); + } + + public function isSelectableColumn(string $name): bool + { + return true; + } +} diff --git a/library/Icingadb/Model/Behavior/ReRoute.php b/library/Icingadb/Model/Behavior/ReRoute.php new file mode 100644 index 0000000..d054f00 --- /dev/null +++ b/library/Icingadb/Model/Behavior/ReRoute.php @@ -0,0 +1,83 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model\Behavior; + +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Contract\RewritePathBehavior; +use ipl\Stdlib\Filter; + +class ReRoute implements RewriteFilterBehavior, RewritePathBehavior +{ + protected $routes; + + /** + * Tables with mixed object type entries for which servicegroup filters need to be resolved in multiple steps + * + * @var string[] + */ + const MIXED_TYPE_RELATIONS = ['downtime', 'comment', 'history', 'notification_history']; + + public function __construct(array $routes) + { + $this->routes = $routes; + } + + public function getRoutes(): array + { + return $this->routes; + } + + public function rewriteCondition(Filter\Condition $condition, $relation = null) + { + $remainingPath = $condition->metaData()->get('columnName', ''); + if (strpos($remainingPath, '.') === false) { + return; + } + + if (($path = $this->rewritePath($remainingPath, $relation)) !== null) { + $class = get_class($condition); + $filter = new $class($relation . $path, $condition->getValue()); + if ($condition->metaData()->has('forceOptimization')) { + $filter->metaData()->set( + 'forceOptimization', + $condition->metaData()->get('forceOptimization') + ); + } + + if ( + in_array(substr($relation, 0, -1), self::MIXED_TYPE_RELATIONS) + && substr($remainingPath, 0, 13) === 'servicegroup.' + ) { + $applyAll = Filter::all(); + $applyAll->add(Filter::equal($relation . 'object_type', 'host')); + + $orgFilter = clone $filter; + $orgFilter->setColumn($relation . 'host.' . $path); + + $applyAll->add($orgFilter); + + $filter = Filter::any($filter, $applyAll); + } + + return $filter; + } + } + + public function rewritePath(string $path, ?string $relation = null): ?string + { + $dot = strpos($path, '.'); + if ($dot !== false) { + $routeName = substr($path, 0, $dot); + } else { + $routeName = $path; + } + + if (isset($this->routes[$routeName])) { + return $this->routes[$routeName] . ($dot !== false ? substr($path, $dot) : ''); + } + + return null; + } +} diff --git a/library/Icingadb/Model/Checkcommand.php b/library/Icingadb/Model/Checkcommand.php new file mode 100644 index 0000000..400a24b --- /dev/null +++ b/library/Icingadb/Model/Checkcommand.php @@ -0,0 +1,86 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Checkcommand extends Model +{ + public function getTableName() + { + return 'checkcommand'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'zone_id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'command', + 'timeout' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'zone_id' => t('Zone Id'), + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Checkcommand Name Checksum'), + 'properties_checksum' => t('Checkcommand Properties Checksum'), + 'name' => t('Checkcommand Name'), + 'name_ci' => t('Checkcommand Name (CI)'), + 'command' => t('Checkcommand'), + 'timeout' => t('Checkcommand Timeout') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'zone_id', + 'environment_id', + 'name_checksum', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(CheckcommandCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(CheckcommandCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(CheckcommandCustomvar::class); + + $relations->hasMany('argument', CheckcommandArgument::class); + $relations->hasMany('envvar', CheckcommandEnvvar::class); + $relations->hasMany('host', Host::class); + $relations->hasMany('service', Service::class); + } +} diff --git a/library/Icingadb/Model/CheckcommandArgument.php b/library/Icingadb/Model/CheckcommandArgument.php new file mode 100644 index 0000000..3fb4ad5 --- /dev/null +++ b/library/Icingadb/Model/CheckcommandArgument.php @@ -0,0 +1,75 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class CheckcommandArgument extends Model +{ + public function getTableName() + { + return 'checkcommand_argument'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'checkcommand_id', + 'argument_key', + 'environment_id', + 'properties_checksum', + 'argument_value', + 'argument_order', + 'description', + 'argument_key_override', + 'repeat_key', + 'required', + 'set_if', + 'skip_key' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'checkcommand_id' => t('Checkcommand Id'), + 'argument_key' => t('Checkcommand Argument Name'), + 'environment_id' => t('Environment Id'), + 'properties_checksum' => t('Checkcommand Argument Properties Checksum'), + 'argument_value' => t('Checkcommand Argument Value'), + 'argument_order' => t('Checkcommand Argument Position'), + 'description' => t('Checkcommand Argument Description'), + 'argument_key_override' => t('Checkcommand Argument Actual Name'), + 'repeat_key' => t('Checkcommand Argument Repeated'), + 'required' => t('Checkcommand Argument Required'), + 'set_if' => t('Checkcommand Argument Condition'), + 'skip_key' => t('Checkcommand Argument Without Name') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'checkcommand_id', + 'environment_id', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('checkcommand', Checkcommand::class); + } +} diff --git a/library/Icingadb/Model/CheckcommandCustomvar.php b/library/Icingadb/Model/CheckcommandCustomvar.php new file mode 100644 index 0000000..0a834b5 --- /dev/null +++ b/library/Icingadb/Model/CheckcommandCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class CheckcommandCustomvar extends Model +{ + public function getTableName() + { + return 'checkcommand_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'checkcommand_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'checkcommand_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('checkcommand', Checkcommand::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/CheckcommandEnvvar.php b/library/Icingadb/Model/CheckcommandEnvvar.php new file mode 100644 index 0000000..517a45f --- /dev/null +++ b/library/Icingadb/Model/CheckcommandEnvvar.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class CheckcommandEnvvar extends Model +{ + public function getTableName() + { + return 'checkcommand_envvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'checkcommand_id', + 'envvar_key', + 'environment_id', + 'properties_checksum', + 'envvar_value' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'checkcommand_id' => t('Checkcommand Id'), + 'envvar_key' => t('Checkcommand Envvar Name'), + 'environment_id' => t('Environment Id'), + 'properties_checksum' => t('Checkcommand Properties Checksum'), + 'envvar_value' => t('Checkcommand Envvar Value') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'checkcommand_id', + 'environment_id', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('checkcommand', Checkcommand::class); + } +} diff --git a/library/Icingadb/Model/Comment.php b/library/Icingadb/Model/Comment.php new file mode 100644 index 0000000..bcdd5e0 --- /dev/null +++ b/library/Icingadb/Model/Comment.php @@ -0,0 +1,114 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Comment extends Model +{ + public function getTableName() + { + return 'comment'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'object_type', + 'host_id', + 'service_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'author', + 'text', + 'entry_type', + 'entry_time', + 'is_persistent', + 'is_sticky', + 'expire_time', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'name_checksum' => t('Comment Name Checksum'), + 'properties_checksum' => t('Comment Properties Checksum'), + 'name' => t('Comment Name'), + 'author' => t('Comment Author'), + 'text' => t('Comment Text'), + 'entry_type' => t('Comment Type'), + 'entry_time' => t('Comment Entry Time'), + 'is_persistent' => t('Comment Is Persistent'), + 'is_sticky' => t('Comment Is Sticky'), + 'expire_time' => t('Comment Expire Time'), + 'zone_id' => t('Zone Id') + ]; + } + + public function getSearchColumns() + { + return ['text']; + } + + public function getDefaultSort() + { + return 'comment.entry_time desc'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'is_persistent', + 'is_sticky' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'entry_time', + 'expire_time' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'host_id', + 'service_id', + 'name_checksum', + 'properties_checksum', + 'zone_id' + ])); + + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('host', Host::class)->setJoinType('LEFT'); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + $relations->belongsTo('zone', Zone::class); + } +} diff --git a/library/Icingadb/Model/CommentHistory.php b/library/Icingadb/Model/CommentHistory.php new file mode 100644 index 0000000..5f03e68 --- /dev/null +++ b/library/Icingadb/Model/CommentHistory.php @@ -0,0 +1,107 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * Model for table `comment_history` + * + * Please note that using this model will fetch history entries for decommissioned services. To avoid this, + * the query needs a `comment_history.service_id IS NULL OR comment_history_service.id IS NOT NULL` where. + */ +class CommentHistory extends Model +{ + public function getTableName() + { + return 'comment_history'; + } + + public function getKeyName() + { + return 'comment_id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'object_type', + 'host_id', + 'service_id', + 'entry_time', + 'author', + 'removed_by', + 'comment', + 'entry_type', + 'is_persistent', + 'is_sticky', + 'expire_time', + 'remove_time', + 'has_been_removed' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'endpoint_id' => t('Endpoint Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'entry_time' => t('Comment Entry Time'), + 'author' => t('Comment Author'), + 'removed_by' => t('Comment Removed By'), + 'comment' => t('Comment Text'), + 'entry_type' => t('Comment Entry Type'), + 'is_persistent' => t('Comment Is Persistent'), + 'is_sticky' => t('Comment Is Sticky'), + 'expire_time' => t('Comment Expire Time'), + 'remove_time' => t('Comment Remove Time'), + 'has_been_removed' => t('Comment Has Been Removed') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'is_persistent', + 'is_sticky', + 'has_been_removed' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'entry_time', + 'expire_time', + 'remove_time' + ])); + + $behaviors->add(new Binary([ + 'comment_id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('endpoint', Endpoint::class); + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('history', History::class) + ->setCandidateKey('comment_id') + ->setForeignKey('comment_history_id'); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/Customvar.php b/library/Icingadb/Model/Customvar.php new file mode 100644 index 0000000..e043229 --- /dev/null +++ b/library/Icingadb/Model/Customvar.php @@ -0,0 +1,72 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Customvar extends Model +{ + public function getTableName() + { + return 'customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'name', + 'value' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + + $relations->belongsToMany('checkcommand', Checkcommand::class) + ->through(CheckcommandCustomvar::class); + $relations->belongsToMany('eventcommand', Eventcommand::class) + ->through(EventcommandCustomvar::class); + $relations->belongsToMany('host', Host::class) + ->through(HostCustomvar::class); + $relations->belongsToMany('hostgroup', Hostgroup::class) + ->through(HostgroupCustomvar::class); + $relations->belongsToMany('notification', Notification::class) + ->through(NotificationCustomvar::class); + $relations->belongsToMany('notificationcommand', Notificationcommand::class) + ->through(NotificationcommandCustomvar::class); + $relations->belongsToMany('service', Service::class) + ->through(ServiceCustomvar::class); + $relations->belongsToMany('servicegroup', Servicegroup::class) + ->through(ServicegroupCustomvar::class); + $relations->belongsToMany('timeperiod', Timeperiod::class) + ->through(TimeperiodCustomvar::class); + $relations->belongsToMany('user', User::class) + ->through(UserCustomvar::class); + $relations->belongsToMany('usergroup', Usergroup::class) + ->through(UsergroupCustomvar::class); + + $relations->hasMany('customvar_flat', CustomvarFlat::class); + } +} diff --git a/library/Icingadb/Model/CustomvarFlat.php b/library/Icingadb/Model/CustomvarFlat.php new file mode 100644 index 0000000..99d8aca --- /dev/null +++ b/library/Icingadb/Model/CustomvarFlat.php @@ -0,0 +1,167 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Query; +use ipl\Orm\Relations; +use Traversable; + +class CustomvarFlat extends Model +{ + public function getTableName() + { + return 'customvar_flat'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'customvar_id', + 'flatname_checksum', + 'flatname', + 'flatvalue' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'customvar_id', + 'flatname_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('customvar', Customvar::class); + + $relations->belongsToMany('checkcommand', Checkcommand::class) + ->through(CheckcommandCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('eventcommand', Eventcommand::class) + ->through(EventcommandCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('host', Host::class) + ->through(HostCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('hostgroup', Hostgroup::class) + ->through(HostgroupCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('notification', Notification::class) + ->through(NotificationCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('notificationcommand', Notificationcommand::class) + ->through(NotificationcommandCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('service', Service::class) + ->through(ServiceCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('servicegroup', Servicegroup::class) + ->through(ServicegroupCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('timeperiod', Timeperiod::class) + ->through(TimeperiodCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('user', User::class) + ->through(UserCustomvar::class) + ->setCandidateKey('customvar_id'); + $relations->belongsToMany('usergroup', Usergroup::class) + ->through(UsergroupCustomvar::class) + ->setCandidateKey('customvar_id'); + } + + /** + * Restore flattened custom variables to their previous structure + * + * @param Traversable $flattenedVars + * + * @return array + */ + public function unFlattenVars(Traversable $flattenedVars): array + { + $registerValue = function (&$data, $source, $path, $value) use (&$registerValue) { + $step = array_shift($path); + + $isIndex = (bool) preg_match('/^\[(\d+)]$/', $step, $m); + if ($isIndex) { + $step = $m[1]; + } + + if ($source !== null) { + while (! isset($source[$step])) { + if ($isIndex) { + $step = sprintf('[%d]', $step); + $isIndex = false; + } else { + if (empty($path)) { + break; + } + + $step = implode('.', [$step, array_shift($path)]); + } + } + } + + if (! empty($path)) { + if (! isset($data[$step])) { + $data[$step] = []; + } + + $registerValue($data[$step], $source[$step] ?? null, $path, $value); + } else { + // Since empty custom vars of type dictionaries and arrays have null values in customvar_flat table, + // we won't be able to render them as such. Therefore, we have to use the value of the `customvar` + // table if it's not null, otherwise the current value, which is a "null" string. + $data[$step] = $value === null && ($source[$step] ?? null) === [] ? $source[$step] : $value; + } + }; + + if ($flattenedVars instanceof Query) { + $flattenedVars->withColumns(['customvar.name', 'customvar.value']); + } + + $vars = []; + foreach ($flattenedVars as $var) { + if (isset($var->customvar->name)) { + $var->customvar->value = json_decode($var->customvar->value, true); + + $realName = $var->customvar->name; + $source = [$realName => $var->customvar->value]; + + $sourcePath = ltrim(substr($var->flatname, strlen($realName)), '.'); + $path = array_merge( + [$realName], + $sourcePath + ? preg_split('/(?<=\w|])\.|(?<!^|\.)(?=\[)/', $sourcePath) + : [] + ); + } else { + $path = explode('.', $var->flatname); + $source = null; + } + + $registerValue($vars, $source, $path, $var->flatvalue); + + if (isset($var->customvar->name)) { + $var->customvar->name = null; + $var->customvar->value = null; + } + } + + return $vars; + } +} diff --git a/library/Icingadb/Model/Downtime.php b/library/Icingadb/Model/Downtime.php new file mode 100644 index 0000000..7f600e9 --- /dev/null +++ b/library/Icingadb/Model/Downtime.php @@ -0,0 +1,144 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Downtime extends Model +{ + public function getTableName() + { + return 'downtime'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'triggered_by_id', + 'parent_id', + 'object_type', + 'host_id', + 'service_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'author', + 'comment', + 'entry_time', + 'scheduled_start_time', + 'scheduled_end_time', + 'scheduled_duration', + 'is_flexible', + 'flexible_duration', + 'is_in_effect', + 'start_time', + 'end_time', + 'duration', + 'scheduled_by', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'triggered_by_id' => t('Triggered By Downtime Id'), + 'parent_id' => t('Parent Downtime Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'name_checksum' => t('Downtime Name Checksum'), + 'properties_checksum' => t('Downtime Properties Checksum'), + 'name' => t('Downtime Name'), + 'author' => t('Downtime Author'), + 'comment' => t('Downtime Comment'), + 'entry_time' => t('Downtime Entry Time'), + 'scheduled_start_time' => t('Downtime Scheduled Start'), + 'scheduled_end_time' => t('Downtime Scheduled End'), + 'scheduled_duration' => t('Downtime Scheduled Duration'), + 'is_flexible' => t('Downtime Is Flexible'), + 'flexible_duration' => t('Downtime Flexible Duration'), + 'is_in_effect' => t('Downtime Is In Effect'), + 'start_time' => t('Downtime Actual Start'), + 'end_time' => t('Downtime Actual End'), + 'duration' => t('Downtime Duration'), + 'scheduled_by' => t('Scheduled By Downtime'), + 'zone_id' => t('Zone Id') + ]; + } + + public function getSearchColumns() + { + return ['comment']; + } + + public function getDefaultSort() + { + return ['downtime.is_in_effect desc', 'downtime.start_time desc']; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'is_flexible', + 'is_in_effect' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'entry_time', + 'scheduled_start_time', + 'scheduled_end_time', + 'start_time', + 'end_time' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'triggered_by_id', + 'parent_id', + 'host_id', + 'service_id', + 'name_checksum', + 'properties_checksum', + 'zone_id' + ])); + + // As long as the rewriteCondition() expects only Filter\Condition as a first argument + // We have to add this reroute behavior after the binary because the filter condition might + // be transformed into a filter chain! + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('triggered_by', self::class) + ->setCandidateKey('triggered_by_id') + ->setJoinType('LEFT'); + $relations->belongsTo('parent', self::class) + ->setCandidateKey('parent_id') + ->setJoinType('LEFT'); + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('host', Host::class)->setJoinType('LEFT'); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + $relations->belongsTo('zone', Zone::class); + } +} diff --git a/library/Icingadb/Model/DowntimeHistory.php b/library/Icingadb/Model/DowntimeHistory.php new file mode 100644 index 0000000..7ba992f --- /dev/null +++ b/library/Icingadb/Model/DowntimeHistory.php @@ -0,0 +1,128 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * Model for table `downtime_history` + * + * Please note that using this model will fetch history entries for decommissioned services. To avoid this, + * the query needs a `downtime_history.service_id IS NULL OR downtime_history_service.id IS NOT NULL` where. + */ +class DowntimeHistory extends Model +{ + public function getTableName() + { + return 'downtime_history'; + } + + public function getKeyName() + { + return 'downtime_id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'triggered_by_id', + 'parent_id', + 'object_type', + 'host_id', + 'service_id', + 'entry_time', + 'author', + 'cancelled_by', + 'comment', + 'is_flexible', + 'flexible_duration', + 'scheduled_start_time', + 'scheduled_end_time', + 'start_time', + 'end_time', + 'has_been_cancelled', + 'trigger_time', + 'cancel_time' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'endpoint_id' => t('Endpoint Id'), + 'triggered_by_id' => t('Triggered By Downtime Id'), + 'parent_id' => t('Parent Downtime Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'entry_time' => t('Downtime Entry Time'), + 'author' => t('Downtime Author'), + 'cancelled_by' => t('Downtime Cancelled By'), + 'comment' => t('Downtime Comment'), + 'is_flexible' => t('Downtime Is Flexible'), + 'flexible_duration' => t('Downtime Flexible Duration'), + 'scheduled_start_time' => t('Downtime Scheduled Start'), + 'scheduled_end_time' => t('Downtime Scheduled End'), + 'start_time' => t('Downtime Actual Start'), + 'end_time' => t('Downtime Actual End'), + 'has_been_cancelled' => t('Downtime Has Been Cancelled'), + 'trigger_time' => t('Downtime Trigger Time'), + 'cancel_time' => t('Downtime Cancel Time') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'is_flexible', + 'has_been_cancelled' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'entry_time', + 'scheduled_start_time', + 'scheduled_end_time', + 'start_time', + 'end_time', + 'trigger_time', + 'cancel_time' + ])); + + $behaviors->add(new Binary([ + 'downtime_id', + 'environment_id', + 'endpoint_id', + 'triggered_by_id', + 'parent_id', + 'host_id', + 'service_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('triggered_by', self::class) + ->setCandidateKey('triggered_by_id') + ->setJoinType('LEFT'); + $relations->belongsTo('parent', self::class) + ->setCandidateKey('parent_id') + ->setJoinType('LEFT'); + $relations->belongsTo('endpoint', Endpoint::class); + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('history', History::class) + ->setCandidateKey('downtime_id') + ->setForeignKey('downtime_history_id'); + $relations->belongsTo('host', Host::class)->setJoinType('LEFT'); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/Endpoint.php b/library/Icingadb/Model/Endpoint.php new file mode 100644 index 0000000..257001b --- /dev/null +++ b/library/Icingadb/Model/Endpoint.php @@ -0,0 +1,69 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Endpoint extends Model +{ + public function getTableName() + { + return 'endpoint'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Endpoint Name Checksum'), + 'properties_checksum' => t('Endpoint Properties Checksum'), + 'name' => t('Endpoint Name'), + 'name_ci' => t('Endpoint Name (CI)'), + 'zone_id' => t('Zone Id') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'zone_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->hasMany('host', Host::class) + ->setForeignKey('command_endpoint_id'); + $relations->hasMany('service', Service::class) + ->setForeignKey('command_endpoint_id'); + } +} diff --git a/library/Icingadb/Model/Environment.php b/library/Icingadb/Model/Environment.php new file mode 100644 index 0000000..919ca1c --- /dev/null +++ b/library/Icingadb/Model/Environment.php @@ -0,0 +1,105 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Environment extends Model +{ + public function getTableName() + { + return 'environment'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'name' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'name' => t('Environment Name') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->hasMany('acknowledgement_history', AcknowledgementHistory::class); + $relations->hasMany('action_url', ActionUrl::class); + $relations->hasMany('checkcommand', Checkcommand::class); + $relations->hasMany('checkcommand_argument', CheckcommandArgument::class); + $relations->hasMany('checkcommand_customvar', CheckcommandCustomvar::class); + $relations->hasMany('checkcommand_envvar', CheckcommandEnvvar::class); + $relations->hasMany('comment', Comment::class); + $relations->hasMany('comment_history', CommentHistory::class); + $relations->hasMany('customvar', Customvar::class); + $relations->hasMany('customvar_flat', CustomvarFlat::class); + $relations->hasMany('downtime', Downtime::class); + $relations->hasMany('downtime_history', DowntimeHistory::class); + $relations->hasMany('endpoint', Endpoint::class); + $relations->hasMany('eventcommand', Eventcommand::class); + $relations->hasMany('eventcommand_argument', EventcommandArgument::class); + $relations->hasMany('eventcommand_customvar', EventcommandCustomvar::class); + $relations->hasMany('eventcommand_envvar', EventcommandEnvvar::class); + $relations->hasMany('flapping_history', FlappingHistory::class); + $relations->hasMany('history', History::class); + $relations->hasMany('host', Host::class); + $relations->hasMany('host_customvar', HostCustomvar::class); + $relations->hasMany('host_state', HostState::class); + $relations->hasMany('hostgroup', Hostgroup::class); + $relations->hasMany('hostgroup_customvar', HostgroupCustomvar::class); + $relations->hasMany('hostgroup_member', HostgroupMember::class); + $relations->hasMany('instance', Instance::class); + $relations->hasMany('icon_image', IconImage::class); + $relations->hasMany('notes_url', NotesUrl::class); + $relations->hasMany('notification', Notification::class); + $relations->hasMany('notification_customvar', NotificationCustomvar::class); + $relations->hasMany('notification_history', NotificationHistory::class); + //$relations->hasMany('notification_recipient', NotificationRecipient::class); + $relations->hasMany('notification_user', NotificationUser::class); + $relations->hasMany('notification_usergroup', NotificationUsergroup::class); + $relations->hasMany('notificationcommand', Notificationcommand::class); + $relations->hasMany('notificationcommand_argument', NotificationcommandArgument::class); + $relations->hasMany('notificationcommand_customvar', NotificationcommandCustomvar::class); + $relations->hasMany('notificationcommand_envvar', NotificationcommandEnvvar::class); + $relations->hasMany('service', Service::class); + $relations->hasMany('service_customvar', ServiceCustomvar::class); + $relations->hasMany('service_state', ServiceState::class); + $relations->hasMany('servicegroup', Servicegroup::class); + $relations->hasMany('servicegroup_customvar', ServicegroupCustomvar::class); + $relations->hasMany('servicegroup_member', ServicegroupMember::class); + $relations->hasMany('state_history', StateHistory::class); + $relations->hasMany('timeperiod', Timeperiod::class); + $relations->hasMany('timeperiod_customvar', TimeperiodCustomvar::class); + $relations->hasMany('timeperiod_override_exclude', TimeperiodOverrideExclude::class); + $relations->hasMany('timeperiod_override_include', TimeperiodOverrideInclude::class); + $relations->hasMany('timeperiod_range', TimeperiodRange::class); + $relations->hasMany('user', User::class); + $relations->hasMany('user_customvar', UserCustomvar::class); + //$relations->hasMany('user_notification_history', UserNotificationHistory::class); + $relations->hasMany('usergroup', Usergroup::class); + $relations->hasMany('usergroup_customvar', UsergroupCustomvar::class); + $relations->hasMany('usergroup_member', UsergroupMember::class); + $relations->hasMany('zone', Zone::class); + } +} diff --git a/library/Icingadb/Model/Eventcommand.php b/library/Icingadb/Model/Eventcommand.php new file mode 100644 index 0000000..ad18e22 --- /dev/null +++ b/library/Icingadb/Model/Eventcommand.php @@ -0,0 +1,86 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Eventcommand extends Model +{ + public function getTableName() + { + return 'eventcommand'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'zone_id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'command', + 'timeout' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'zone_id' => t('Zone Id'), + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Eventcommand Name Checksum'), + 'properties_checksum' => t('Eventcommand Properties Checksum'), + 'name' => t('Eventcommand Name'), + 'name_ci' => t('Eventcommand Name (CI)'), + 'command' => t('Eventcommand'), + 'timeout' => t('Eventcommand Timeout') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'zone_id', + 'environment_id', + 'name_checksum', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(EventcommandCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(EventcommandCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(EventcommandCustomvar::class); + + $relations->hasMany('argument', EventcommandArgument::class); + $relations->hasMany('envvar', EventcommandEnvvar::class); + $relations->hasMany('host', Host::class); + $relations->hasMany('service', Service::class); + } +} diff --git a/library/Icingadb/Model/EventcommandArgument.php b/library/Icingadb/Model/EventcommandArgument.php new file mode 100644 index 0000000..485e5d3 --- /dev/null +++ b/library/Icingadb/Model/EventcommandArgument.php @@ -0,0 +1,75 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class EventcommandArgument extends Model +{ + public function getTableName() + { + return 'eventcommand_argument'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'eventcommand_id', + 'argument_key', + 'environment_id', + 'properties_checksum', + 'argument_value', + 'argument_order', + 'description', + 'argument_key_override', + 'repeat_key', + 'required', + 'set_if', + 'skip_key' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'eventcommand_id' => t('Eventcommand Id'), + 'argument_key' => t('Eventcommand Argument Name'), + 'environment_id' => t('Environment Id'), + 'properties_checksum' => t('Eventcommand Argument Properties Checksum'), + 'argument_value' => t('Eventcommand Argument Value'), + 'argument_order' => t('Eventcommand Argument Position'), + 'description' => t('Eventcommand Argument Description'), + 'argument_key_override' => t('Eventcommand Argument Actual Name'), + 'repeat_key' => t('Eventcommand Argument Repeated'), + 'required' => t('Eventcommand Argument Required'), + 'set_if' => t('Eventcommand Argument Condition'), + 'skip_key' => t('Eventcommand Argument Without Name') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'eventcommand_id', + 'environment_id', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('eventcommand', Eventcommand::class); + } +} diff --git a/library/Icingadb/Model/EventcommandCustomvar.php b/library/Icingadb/Model/EventcommandCustomvar.php new file mode 100644 index 0000000..3d1fa48 --- /dev/null +++ b/library/Icingadb/Model/EventcommandCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class EventcommandCustomvar extends Model +{ + public function getTableName() + { + return 'eventcommand_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'eventcommand_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'eventcommand_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('eventcommand', Eventcommand::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/EventcommandEnvvar.php b/library/Icingadb/Model/EventcommandEnvvar.php new file mode 100644 index 0000000..3883bef --- /dev/null +++ b/library/Icingadb/Model/EventcommandEnvvar.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class EventcommandEnvvar extends Model +{ + public function getTableName() + { + return 'eventcommand_envvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'eventcommand_id', + 'envvar_key', + 'environment_id', + 'properties_checksum', + 'envvar_value' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'eventcommand_id' => t('Eventcommand Id'), + 'envvar_key' => t('Eventcommand Envvar Name'), + 'environment_id' => t('Environment Id'), + 'properties_checksum' => t('Eventcommand Envvar Properties Checksum'), + 'envvar_value' => t('Eventcommand Envvar Value') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'eventcommand_id', + 'environment_id', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('eventcommand', Eventcommand::class); + } +} diff --git a/library/Icingadb/Model/FlappingHistory.php b/library/Icingadb/Model/FlappingHistory.php new file mode 100644 index 0000000..69711eb --- /dev/null +++ b/library/Icingadb/Model/FlappingHistory.php @@ -0,0 +1,91 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * Model for table `flapping_history` + * + * Please note that using this model will fetch history entries for decommissioned services. To avoid this, + * the query needs a `flapping_history.service_id IS NULL OR flapping_history_service.id IS NOT NULL` where. + */ +class FlappingHistory extends Model +{ + public function getTableName() + { + return 'flapping_history'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'object_type', + 'host_id', + 'service_id', + 'start_time', + 'end_time', + 'percent_state_change_start', + 'percent_state_change_end', + 'flapping_threshold_low', + 'flapping_threshold_high' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'endpoint_id' => t('Endpoint Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'start_time' => t('Flapping Start Time'), + 'end_time' => t('Flapping End Time'), + 'percent_state_change_start' => t('Flapping Percent State Change Start'), + 'percent_state_change_end' => t('Flapping Percent State Change End'), + 'flapping_threshold_low' => t('Flapping Threshold Low'), + 'flapping_threshold_high' => t('Flapping Threshold High') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new MillisecondTimestamp([ + 'start_time', + 'end_time' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('endpoint', Endpoint::class); + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('history', History::class) + ->setCandidateKey('id') + ->setForeignKey('flapping_history_id'); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/History.php b/library/Icingadb/Model/History.php new file mode 100644 index 0000000..a34b3bd --- /dev/null +++ b/library/Icingadb/Model/History.php @@ -0,0 +1,127 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * Model for table `history` + * + * Please note that using this model will fetch history entries for decommissioned services. To avoid + * this, the query needs a `history.service_id IS NULL OR history_service.id IS NOT NULL` where. + */ +class History extends Model +{ + public function getTableName() + { + return 'history'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'object_type', + 'host_id', + 'service_id', + 'comment_history_id', + 'downtime_history_id', + 'flapping_history_id', + 'notification_history_id', + 'acknowledgement_history_id', + 'state_history_id', + 'event_type', + 'event_time' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'endpoint_id' => t('Endpoint Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'event_type' => t('Event Type'), + 'event_time' => t('Event Time') + ]; + } + + public function getDefaultSort() + { + return 'history.event_time desc, history.event_type desc'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new MillisecondTimestamp([ + 'event_time' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id', + 'comment_history_id', + 'downtime_history_id', + 'flapping_history_id', + 'notification_history_id', + 'acknowledgement_history_id', + 'state_history_id' + ])); + + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('endpoint', Endpoint::class); + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + + $relations->hasOne('comment', CommentHistory::class) + ->setCandidateKey('comment_history_id') + ->setForeignKey('comment_id') + ->setJoinType('LEFT'); + $relations->hasOne('downtime', DowntimeHistory::class) + ->setCandidateKey('downtime_history_id') + ->setForeignKey('downtime_id') + ->setJoinType('LEFT'); + $relations->hasOne('flapping', FlappingHistory::class) + ->setCandidateKey('flapping_history_id') + ->setForeignKey('id') + ->setJoinType('LEFT'); + $relations->hasOne('notification', NotificationHistory::class) + ->setCandidateKey('notification_history_id') + ->setForeignKey('id') + ->setJoinType('LEFT'); + $relations->hasOne('acknowledgement', AcknowledgementHistory::class) + ->setCandidateKey('acknowledgement_history_id') + ->setForeignKey('id') + ->setJoinType('LEFT'); + $relations->hasOne('state', StateHistory::class) + ->setCandidateKey('state_history_id') + ->setForeignKey('id') + ->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/Host.php b/library/Icingadb/Model/Host.php new file mode 100644 index 0000000..e1296c8 --- /dev/null +++ b/library/Icingadb/Model/Host.php @@ -0,0 +1,234 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Defaults; +use ipl\Orm\Model; +use ipl\Orm\Relations; +use ipl\Orm\ResultSet; + +/** + * Host model. + */ +class Host extends Model +{ + use Auth; + + public function getTableName() + { + return 'host'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'display_name', + 'address', + 'address6', + 'address_bin', + 'address6_bin', + 'checkcommand_name', + 'checkcommand_id', + 'max_check_attempts', + 'check_timeperiod_name', + 'check_timeperiod_id', + 'check_timeout', + 'check_interval', + 'check_retry_interval', + 'active_checks_enabled', + 'passive_checks_enabled', + 'event_handler_enabled', + 'notifications_enabled', + 'flapping_enabled', + 'flapping_threshold_low', + 'flapping_threshold_high', + 'perfdata_enabled', + 'eventcommand_name', + 'eventcommand_id', + 'is_volatile', + 'action_url_id', + 'notes_url_id', + 'notes', + 'icon_image_id', + 'icon_image_alt', + 'zone_name', + 'zone_id', + 'command_endpoint_name', + 'command_endpoint_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Host Name Checksum'), + 'properties_checksum' => t('Host Properties Checksum'), + 'name' => t('Host Name'), + 'name_ci' => t('Host Name (CI)'), + 'display_name' => t('Host Display Name'), + 'address' => t('Host Address (IPv4)'), + 'address6' => t('Host Address (IPv6)'), + 'address_bin' => t('Host Address (IPv4, Binary)'), + 'address6_bin' => t('Host Address (IPv6, Binary)'), + 'checkcommand_name' => t('Checkcommand Name'), + 'checkcommand_id' => t('Checkcommand Id'), + 'max_check_attempts' => t('Host Max Check Attempts'), + 'check_timeperiod_name' => t('Check Timeperiod Name'), + 'check_timeperiod_id' => t('Check Timeperiod Id'), + 'check_timeout' => t('Host Check Timeout'), + 'check_interval' => t('Host Check Interval'), + 'check_retry_interval' => t('Host Check Retry Inverval'), + 'active_checks_enabled' => t('Host Active Checks Enabled'), + 'passive_checks_enabled' => t('Host Passive Checks Enabled'), + 'event_handler_enabled' => t('Host Event Handler Enabled'), + 'notifications_enabled' => t('Host Notifications Enabled'), + 'flapping_enabled' => t('Host Flapping Enabled'), + 'flapping_threshold_low' => t('Host Flapping Threshold Low'), + 'flapping_threshold_high' => t('Host Flapping Threshold High'), + 'perfdata_enabled' => t('Host Performance Data Enabled'), + 'eventcommand_name' => t('Eventcommand Name'), + 'eventcommand_id' => t('Eventcommand Id'), + 'is_volatile' => t('Host Is Volatile'), + 'action_url_id' => t('Action Url Id'), + 'notes_url_id' => t('Notes Url Id'), + 'notes' => t('Host Notes'), + 'icon_image_id' => t('Icon Image Id'), + 'icon_image_alt' => t('Icon Image Alt'), + 'zone_name' => t('Zone Name'), + 'zone_id' => t('Zone Id'), + 'command_endpoint_name' => t('Endpoint Name'), + 'command_endpoint_id' => t('Endpoint Id') + ]; + } + + public function getSearchColumns() + { + return ['name_ci', 'display_name']; + } + + public function getDefaultSort() + { + return 'host.display_name'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'active_checks_enabled', + 'passive_checks_enabled', + 'event_handler_enabled', + 'notifications_enabled', + 'flapping_enabled', + 'is_volatile' + ])); + + $behaviors->add(new ReRoute([ + 'servicegroup' => 'service.servicegroup', + 'user' => 'notification.user', + 'usergroup' => 'notification.usergroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'address_bin', + 'address6_bin', + 'checkcommand_id', + 'check_timeperiod_id', + 'eventcommand_id', + 'action_url_id', + 'notes_url_id', + 'icon_image_id', + 'zone_id', + 'command_endpoint_id' + ])); + } + + public function createDefaults(Defaults $defaults) + { + $defaults->add('vars', function (self $subject) { + if (! $subject->customvar_flat instanceof ResultSet) { + $this->applyRestrictions($subject->customvar_flat); + } + + $vars = []; + foreach ($subject->customvar_flat as $customVar) { + $vars[$customVar->flatname] = $customVar->flatvalue; + } + + return $vars; + }); + + $defaults->add('customvars', function (self $subject) { + if (! $subject->customvar instanceof ResultSet) { + $this->applyRestrictions($subject->customvar); + } + + $vars = []; + foreach ($subject->customvar as $customVar) { + $vars[$customVar->name] = json_decode($customVar->value, true); + } + + return $vars; + }); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('eventcommand', Eventcommand::class); + $relations->belongsTo('checkcommand', Checkcommand::class); + $relations->belongsTo('timeperiod', Timeperiod::class) + ->setCandidateKey('check_timeperiod_id') + ->setJoinType('LEFT'); + $relations->belongsTo('action_url', ActionUrl::class) + ->setCandidateKey('action_url_id') + ->setForeignKey('id'); + $relations->belongsTo('notes_url', NotesUrl::class) + ->setCandidateKey('notes_url_id') + ->setForeignKey('id'); + $relations->belongsTo('icon_image', IconImage::class) + ->setCandidateKey('icon_image_id') + ->setJoinType('LEFT'); + $relations->belongsTo('zone', Zone::class); + $relations->belongsTo('endpoint', Endpoint::class) + ->setCandidateKey('command_endpoint_id'); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(HostCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(HostCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(HostCustomvar::class); + $relations->belongsToMany('hostgroup', Hostgroup::class) + ->through(HostgroupMember::class); + + $relations->hasOne('state', HostState::class)->setJoinType('LEFT'); + $relations->hasMany('comment', Comment::class)->setJoinType('LEFT'); + $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT'); + $relations->hasMany('history', History::class); + $relations->hasMany('notification', Notification::class)->setJoinType('LEFT'); + $relations->hasMany('notification_history', NotificationHistory::class); + $relations->hasMany('service', Service::class)->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/HostCustomvar.php b/library/Icingadb/Model/HostCustomvar.php new file mode 100644 index 0000000..9f7df26 --- /dev/null +++ b/library/Icingadb/Model/HostCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class HostCustomvar extends Model +{ + public function getTableName() + { + return 'host_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'host_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'host_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setForeignKey('customvar_id') + ->setCandidateKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/HostState.php b/library/Icingadb/Model/HostState.php new file mode 100644 index 0000000..efa2752 --- /dev/null +++ b/library/Icingadb/Model/HostState.php @@ -0,0 +1,81 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Common\HostStates; +use ipl\Orm\Relations; + +/** + * Host state model. + */ +class HostState extends State +{ + public function getTableName() + { + return 'host_state'; + } + + public function getKeyName() + { + return 'host_id'; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'state_type' => t('Host State Type'), + 'soft_state' => t('Host Soft State'), + 'hard_state' => t('Host Hard State'), + 'previous_soft_state' => t('Host Previous Soft State'), + 'previous_hard_state' => t('Host Previous Hard State'), + 'check_attempt' => t('Host Check Attempt No.'), + 'severity' => t('Host State Severity'), + 'output' => t('Host Output'), + 'long_output' => t('Host Long Output'), + 'performance_data' => t('Host Performance Data'), + 'normalized_performance_data' => t('Host Normalized Performance Data'), + 'check_commandline' => t('Host Check Commandline'), + 'is_problem' => t('Host Has Problem'), + 'is_handled' => t('Host Is Handled'), + 'is_reachable' => t('Host Is Reachable'), + 'is_flapping' => t('Host Is Flapping'), + 'is_overdue' => t('Host Check Is Overdue'), + 'is_acknowledged' => t('Host Is Acknowledged'), + 'acknowledgement_comment_id' => t('Acknowledgement Comment Id'), + 'in_downtime' => t('Host In Downtime'), + 'execution_time' => t('Host Check Execution Time'), + 'latency' => t('Host Check Latency'), + 'check_timeout' => t('Host Check Timeout'), + 'check_source' => t('Host Check Source'), + 'last_update' => t('Host Last Update'), + 'last_state_change' => t('Host Last State Change'), + 'next_check' => t('Host Next Check'), + 'next_update' => t('Host Next Update') + ]; + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('host', Host::class); + $relations->hasOne('last_comment', LastHostComment::class) + ->setCandidateKey('last_comment_id') + ->setForeignKey('id') + ->setJoinType('LEFT'); + } + + + public function getStateText(): string + { + return HostStates::text($this->soft_state); + } + + + public function getStateTextTranslated(): string + { + return HostStates::text($this->soft_state); + } +} diff --git a/library/Icingadb/Model/Hostgroup.php b/library/Icingadb/Model/Hostgroup.php new file mode 100644 index 0000000..97930fa --- /dev/null +++ b/library/Icingadb/Model/Hostgroup.php @@ -0,0 +1,92 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Hostgroup extends Model +{ + public function getTableName() + { + return 'hostgroup'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'display_name', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Hostgroup Name Checksum'), + 'properties_checksum' => t('Hostgroup Properties Checksum'), + 'name' => t('Hostgroup Name'), + 'name_ci' => t('Hostgroup Name (CI)'), + 'display_name' => t('Hostgroup Display Name'), + 'zone_id' => t('Zone Id') + ]; + } + + public function getSearchColumns() + { + return ['name_ci', 'display_name']; + } + + public function getDefaultSort() + { + return 'display_name'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'servicegroup' => 'service.servicegroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'zone_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(HostgroupCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(HostgroupCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(HostgroupCustomvar::class); + $relations->belongsToMany('host', Host::class) + ->through(HostgroupMember::class); + $relations->belongsToMany('service', Service::class) + ->through(HostgroupMember::class); + } +} diff --git a/library/Icingadb/Model/HostgroupCustomvar.php b/library/Icingadb/Model/HostgroupCustomvar.php new file mode 100644 index 0000000..41272d1 --- /dev/null +++ b/library/Icingadb/Model/HostgroupCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class HostgroupCustomvar extends Model +{ + public function getTableName() + { + return 'hostgroup_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'hostgroup_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'hostgroup_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('hostgroup', Hostgroup::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/HostgroupMember.php b/library/Icingadb/Model/HostgroupMember.php new file mode 100644 index 0000000..3660e71 --- /dev/null +++ b/library/Icingadb/Model/HostgroupMember.php @@ -0,0 +1,53 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class HostgroupMember extends Model +{ + public function getTableName() + { + return 'hostgroup_member'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'host_id', + 'hostgroup_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'host_id', + 'hostgroup_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('hostgroup', Hostgroup::class); + $relations->belongsTo('host', Host::class); + + $relations->hasMany('service', Service::class) + ->setForeignKey('host_id') + ->setCandidateKey('host_id'); + } +} diff --git a/library/Icingadb/Model/Hostgroupsummary.php b/library/Icingadb/Model/Hostgroupsummary.php new file mode 100644 index 0000000..a9295bb --- /dev/null +++ b/library/Icingadb/Model/Hostgroupsummary.php @@ -0,0 +1,213 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Relations; +use ipl\Orm\UnionModel; +use ipl\Sql\Adapter\Pgsql; +use ipl\Sql\Connection; +use ipl\Sql\Expression; +use ipl\Sql\Select; + +class Hostgroupsummary extends UnionModel +{ + public static function on(Connection $db) + { + $q = parent::on($db); + + $q->on($q::ON_SELECT_ASSEMBLED, function (Select $select) use ($q) { + $model = $q->getModel(); + + $groupBy = $q->getResolver()->qualifyColumnsAndAliases((array) $model->getKeyName(), $model, false); + + // For PostgreSQL, ALL non-aggregate SELECT columns must appear in the GROUP BY clause: + if ($q->getDb()->getAdapter() instanceof Pgsql) { + /** + * Ignore Expressions, i.e. aggregate functions {@see getColumns()}, + * which do not need to be added to the GROUP BY. + */ + $candidates = array_filter($select->getColumns(), 'is_string'); + // Remove already considered columns for the GROUP BY, i.e. the primary key. + $candidates = array_diff_assoc($candidates, $groupBy); + $groupBy = array_merge($groupBy, $candidates); + } + + $select->groupBy($groupBy); + }); + + return $q; + } + + public function getTableName() + { + return 'hostgroup'; + } + + public function getKeyName() + { + return ['id' => 'hostgroup_id']; + } + + public function getColumns() + { + return [ + 'display_name' => 'hostgroup_display_name', + 'hosts_down_handled' => new Expression( + 'SUM(CASE WHEN host_state = 1' + . ' AND (host_handled = \'y\' OR host_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'hosts_down_unhandled' => new Expression( + 'SUM(CASE WHEN host_state = 1' + . ' AND host_handled = \'n\' AND host_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_pending' => new Expression( + 'SUM(CASE WHEN host_state = 99 THEN 1 ELSE 0 END)' + ), + 'hosts_total' => new Expression( + 'SUM(CASE WHEN host_id IS NOT NULL THEN 1 ELSE 0 END)' + ), + 'hosts_up' => new Expression( + 'SUM(CASE WHEN host_state = 0 THEN 1 ELSE 0 END)' + ), + 'hosts_severity' => new Expression('MAX(host_severity)'), + 'name' => 'hostgroup_name', + 'services_critical_handled' => new Expression( + 'SUM(CASE WHEN service_state = 2' + . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_critical_unhandled' => new Expression( + 'SUM(CASE WHEN service_state = 2' + . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_ok' => new Expression( + 'SUM(CASE WHEN service_state = 0 THEN 1 ELSE 0 END)' + ), + 'services_pending' => new Expression( + 'SUM(CASE WHEN service_state = 99 THEN 1 ELSE 0 END)' + ), + 'services_total' => new Expression( + 'SUM(CASE WHEN service_id IS NOT NULL THEN 1 ELSE 0 END)' + ), + 'services_unknown_handled' => new Expression( + 'SUM(CASE WHEN service_state = 3' + . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_unknown_unhandled' => new Expression( + 'SUM(CASE WHEN service_state = 3' + . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_warning_handled' => new Expression( + 'SUM(CASE WHEN service_state = 1' + . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_warning_unhandled' => new Expression( + 'SUM(CASE WHEN service_state = 1' + . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)' + ) + ]; + } + + public function getSearchColumns() + { + return ['display_name']; + } + + public function getDefaultSort() + { + return 'display_name'; + } + + public function getUnions() + { + $unions = [ + [ + Host::class, + [ + 'hostgroup', + 'state' + ], + [ + 'hostgroup_id' => 'hostgroup.id', + 'hostgroup_name' => 'hostgroup.name', + 'hostgroup_display_name' => 'hostgroup.display_name', + 'host_id' => 'host.id', + 'host_state' => 'state.soft_state', + 'host_handled' => 'state.is_handled', + 'host_reachable' => 'state.is_reachable', + 'host_severity' => 'state.severity', + 'service_id' => new Expression('NULL'), + 'service_state' => new Expression('NULL'), + 'service_handled' => new Expression('NULL'), + 'service_reachable' => new Expression('NULL') + ] + ], + [ + Service::class, + [ + 'hostgroup', + 'state' + ], + [ + 'hostgroup_id' => 'hostgroup.id', + 'hostgroup_name' => 'hostgroup.name', + 'hostgroup_display_name' => 'hostgroup.display_name', + 'host_id' => new Expression('NULL'), + 'host_state' => new Expression('NULL'), + 'host_handled' => new Expression('NULL'), + 'host_reachable' => new Expression('NULL'), + 'host_severity' => new Expression('0'), + 'service_id' => 'service.id', + 'service_state' => 'state.soft_state', + 'service_handled' => 'state.is_handled', + 'service_reachable' => 'state.is_reachable' + ] + ], + [ + Hostgroup::class, + [], + [ + 'hostgroup_id' => 'hostgroup.id', + 'hostgroup_name' => 'hostgroup.name', + 'hostgroup_display_name' => 'hostgroup.display_name', + 'host_id' => new Expression('NULL'), + 'host_state' => new Expression('NULL'), + 'host_handled' => new Expression('NULL'), + 'host_reachable' => new Expression('NULL'), + 'host_severity' => new Expression('0'), + 'service_id' => new Expression('NULL'), + 'service_state' => new Expression('NULL'), + 'service_handled' => new Expression('NULL'), + 'service_reachable' => new Expression('NULL') + ] + ] + ]; + + return $unions; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id' + ])); + + // This is because there is no better way + (new Hostgroup())->createBehaviors($behaviors); + } + + public function createRelations(Relations $relations) + { + // This is because there is no better way + (new Hostgroup())->createRelations($relations); + } + + public function getColumnDefinitions() + { + // This is because there is no better way + return (new Hostgroup())->getColumnDefinitions(); + } +} diff --git a/library/Icingadb/Model/HoststateSummary.php b/library/Icingadb/Model/HoststateSummary.php new file mode 100644 index 0000000..93268f3 --- /dev/null +++ b/library/Icingadb/Model/HoststateSummary.php @@ -0,0 +1,78 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Sql\Connection; +use ipl\Sql\Expression; + +class HoststateSummary extends Host +{ + public function getSummaryColumns() + { + return [ + 'hosts_acknowledged' => new Expression( + 'SUM(CASE WHEN host_state.is_acknowledged = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_active_checks_enabled' => new Expression( + 'SUM(CASE WHEN host.active_checks_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_passive_checks_enabled' => new Expression( + 'SUM(CASE WHEN host.passive_checks_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_down_handled' => new Expression( + 'SUM(CASE WHEN host_state.soft_state = 1' + . ' AND (host_state.is_handled = \'y\' OR host_state.is_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'hosts_down_unhandled' => new Expression( + 'SUM(CASE WHEN host_state.soft_state = 1' + . ' AND host_state.is_handled = \'n\' AND host_state.is_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_event_handler_enabled' => new Expression( + 'SUM(CASE WHEN host.event_handler_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_flapping_enabled' => new Expression( + 'SUM(CASE WHEN host.flapping_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_notifications_enabled' => new Expression( + 'SUM(CASE WHEN host.notifications_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'hosts_pending' => new Expression( + 'SUM(CASE WHEN host_state.soft_state = 99 THEN 1 ELSE 0 END)' + ), + 'hosts_problems_unacknowledged' => new Expression( + 'SUM(CASE WHEN host_state.is_problem = \'y\'' + . ' AND host_state.is_acknowledged = \'n\' THEN 1 ELSE 0 END)' + ), + 'hosts_total' => new Expression( + 'SUM(CASE WHEN host.id IS NOT NULL THEN 1 ELSE 0 END)' + ), + 'hosts_up' => new Expression( + 'SUM(CASE WHEN host_state.soft_state = 0 THEN 1 ELSE 0 END)' + ) + ]; + } + + public static function on(Connection $db) + { + $q = parent::on($db); + $q->utilize('state'); + + /** @var static $m */ + $m = $q->getModel(); + $q->columns($m->getSummaryColumns()); + + return $q; + } + + public function getColumns() + { + return array_merge(parent::getColumns(), $this->getSummaryColumns()); + } + + public function getDefaultSort() + { + return null; + } +} diff --git a/library/Icingadb/Model/IconImage.php b/library/Icingadb/Model/IconImage.php new file mode 100644 index 0000000..212234a --- /dev/null +++ b/library/Icingadb/Model/IconImage.php @@ -0,0 +1,55 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class IconImage extends Model +{ + public function getTableName() + { + return 'icon_image'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'icon_image', + 'environment_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'icon_image' => t('Icon Image'), + 'environment_id' => t('Environment Id') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + + $relations->hasMany('host', Host::class); + $relations->hasMany('service', Service::class); + } +} diff --git a/library/Icingadb/Model/Instance.php b/library/Icingadb/Model/Instance.php new file mode 100644 index 0000000..73db565 --- /dev/null +++ b/library/Icingadb/Model/Instance.php @@ -0,0 +1,78 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Instance extends Model +{ + public function getTableName() + { + return 'icingadb_instance'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'heartbeat', + 'responsible', + 'icinga2_active_host_checks_enabled', + 'icinga2_active_service_checks_enabled', + 'icinga2_event_handlers_enabled', + 'icinga2_flap_detection_enabled', + 'icinga2_notifications_enabled', + 'icinga2_performance_data_enabled', + 'icinga2_start_time', + 'icinga2_version' + ]; + } + + public function getDefaultSort() + { + return ['responsible desc', 'heartbeat desc']; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new MillisecondTimestamp([ + 'heartbeat', + 'icinga2_start_time' + ])); + + $behaviors->add(new BoolCast([ + 'responsible', + 'icinga2_active_host_checks_enabled', + 'icinga2_active_service_checks_enabled', + 'icinga2_event_handlers_enabled', + 'icinga2_flap_detection_enabled', + 'icinga2_notifications_enabled', + 'icinga2_performance_data_enabled' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('endpoint', Endpoint::class)->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/LastHostComment.php b/library/Icingadb/Model/LastHostComment.php new file mode 100644 index 0000000..621b204 --- /dev/null +++ b/library/Icingadb/Model/LastHostComment.php @@ -0,0 +1,19 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Relations; + +class LastHostComment extends Comment +{ + public function createRelations(Relations $relations): void + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + $relations->belongsTo('state', HostState::class) + ->setForeignKey('last_comment_id') + ->setCandidateKey('id'); + } +} diff --git a/library/Icingadb/Model/LastServiceComment.php b/library/Icingadb/Model/LastServiceComment.php new file mode 100644 index 0000000..4d44f11 --- /dev/null +++ b/library/Icingadb/Model/LastServiceComment.php @@ -0,0 +1,19 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Relations; + +class LastServiceComment extends Comment +{ + public function createRelations(Relations $relations): void + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + $relations->belongsTo('state', ServiceState::class) + ->setForeignKey('last_comment_id') + ->setCandidateKey('id'); + } +} diff --git a/library/Icingadb/Model/NotesUrl.php b/library/Icingadb/Model/NotesUrl.php new file mode 100644 index 0000000..5865c52 --- /dev/null +++ b/library/Icingadb/Model/NotesUrl.php @@ -0,0 +1,62 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ActionAndNoteUrl; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class NotesUrl extends Model +{ + public function getTableName() + { + return 'notes_url'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'notes_url', + 'environment_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'notes_url' => t('Notes Url'), + 'environment_id' => t('Environment Id') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ActionAndNoteUrl(['notes_url'])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + + $relations->hasMany('host', Host::class) + ->setCandidateKey('id') + ->setForeignKey('notes_url_id'); + $relations->hasMany('service', Service::class) + ->setCandidateKey('id') + ->setForeignKey('notes_url_id'); + } +} diff --git a/library/Icingadb/Model/Notification.php b/library/Icingadb/Model/Notification.php new file mode 100644 index 0000000..8d42301 --- /dev/null +++ b/library/Icingadb/Model/Notification.php @@ -0,0 +1,130 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\Bitmask; +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Notification extends Model +{ + public function getTableName() + { + return 'notification'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'host_id', + 'service_id', + 'notificationcommand_id', + 'times_begin', + 'times_end', + 'notification_interval', + 'timeperiod_id', + 'states', + 'types', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Notification Name Checksum'), + 'properties_checksum' => t('Notification Properties Checksum'), + 'name' => t('Notification Name'), + 'name_ci' => t('Notification Name (CI)'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'notificationcommand_id' => t('Notificationcommand Id'), + 'times_begin' => t('Notification Escalate After'), + 'times_end' => t('Notification Escalate Until'), + 'notification_interval' => t('Notification Interval'), + 'timeperiod_id' => t('Timeperiod Id'), + 'states' => t('Notification State Filter'), + 'types' => t('Notification Type Filter'), + 'zone_id' => t('Zone Id') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + + $behaviors->add(new Bitmask([ + 'states' => [ + 'ok' => 1, + 'warning' => 2, + 'critical' => 4, + 'unknown' => 8, + 'up' => 16, + 'down' => 32 + ], + 'types' => [ + 'downtime_start' => 1, + 'downtime_end' => 2, + 'downtime_removed' => 4, + 'custom' => 8, + 'ack' => 16, + 'problem' => 32, + 'recovery' => 64, + 'flapping_start' => 128, + 'flapping_end' => 256 + ] + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'host_id', + 'service_id', + 'notificationcommand_id', + 'timeperiod_id', + 'zone_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class); + $relations->belongsTo('notificationcommand', Notificationcommand::class); + $relations->belongsTo('timeperiod', Timeperiod::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(NotificationCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(NotificationCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(NotificationCustomvar::class); + $relations->belongsToMany('user', User::class) + ->through('notification_recipient'); + $relations->belongsToMany('usergroup', Usergroup::class) + ->through('notification_recipient'); + } +} diff --git a/library/Icingadb/Model/NotificationCustomvar.php b/library/Icingadb/Model/NotificationCustomvar.php new file mode 100644 index 0000000..620ae5c --- /dev/null +++ b/library/Icingadb/Model/NotificationCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class NotificationCustomvar extends Model +{ + public function getTableName() + { + return 'notification_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'notification_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'notification_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('notification', Notification::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/NotificationHistory.php b/library/Icingadb/Model/NotificationHistory.php new file mode 100644 index 0000000..c635dbb --- /dev/null +++ b/library/Icingadb/Model/NotificationHistory.php @@ -0,0 +1,115 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * Model for table `notification_history` + * + * Please note that using this model will fetch history entries for decommissioned services. To avoid this, the + * query needs a `notification_history.service_id IS NULL OR notification_history_service.id IS NOT NULL` where. + */ +class NotificationHistory extends Model +{ + public function getTableName() + { + return 'notification_history'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'object_type', + 'host_id', + 'service_id', + 'notification_id', + 'type', + 'send_time', + 'state', + 'previous_hard_state', + 'author', + 'text', + 'users_notified' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'id' => t('History Id'), + 'environment_id' => t('Environment Id'), + 'endpoint_id' => t('Endpoint Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'notification_id' => t('Notification Id'), + 'type' => t('Notification Type'), + 'send_time' => t('Notification Sent On'), + 'state' => t('Hard State'), + 'previous_hard_state' => t('Previous Hard State'), + 'author' => t('Notification Author'), + 'text' => t('Notification Text'), + 'users_notified' => t('Users Notified') + ]; + } + + public function getSearchColumns() + { + return ['text']; + } + + public function getDefaultSort() + { + return 'notification_history.send_time desc'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new MillisecondTimestamp([ + 'send_time' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id', + 'notification_id' + ])); + + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('history', History::class) + ->setCandidateKey('id') + ->setForeignKey('notification_history_id'); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + $relations->belongsTo('notification', Notification::class)->setJoinType('LEFT'); + + $relations->belongsToMany('user', User::class) + ->through('user_notification_history'); + } +} diff --git a/library/Icingadb/Model/NotificationUser.php b/library/Icingadb/Model/NotificationUser.php new file mode 100644 index 0000000..ab23ad4 --- /dev/null +++ b/library/Icingadb/Model/NotificationUser.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class NotificationUser extends Model +{ + public function getTableName() + { + return 'notification_user'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'notification_id', + 'user_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'notification_id', + 'user_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('notification', Notification::class); + $relations->belongsTo('user', User::class); + } +} diff --git a/library/Icingadb/Model/NotificationUsergroup.php b/library/Icingadb/Model/NotificationUsergroup.php new file mode 100644 index 0000000..bd60fae --- /dev/null +++ b/library/Icingadb/Model/NotificationUsergroup.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class NotificationUsergroup extends Model +{ + public function getTableName() + { + return 'notification_usergroup'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'notification_id', + 'usergroup_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'notification_id', + 'usergroup_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('notification', Notification::class); + $relations->belongsTo('usergroup', Usergroup::class); + } +} diff --git a/library/Icingadb/Model/Notificationcommand.php b/library/Icingadb/Model/Notificationcommand.php new file mode 100644 index 0000000..6ee2a21 --- /dev/null +++ b/library/Icingadb/Model/Notificationcommand.php @@ -0,0 +1,87 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Notificationcommand extends Model +{ + public function getTableName() + { + return 'notificationcommand'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'zone_id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'command', + 'timeout' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'zone_id' => t('Zone Id'), + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Notificationcommand Name Checksum'), + 'properties_checksum' => t('Notificationcommand Properties Checksum'), + 'name' => t('Notificationcommand Name'), + 'name_ci' => t('Notificationcommand Name (CI)'), + 'command' => t('Notificationcommand'), + 'timeout' => t('Notificationcommand Timeout') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'host' => 'notification.host', + 'hostgroup' => 'notification.host.hostgroup', + 'service' => 'notification.service', + 'servicegroup' => 'notification.service.servicegroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'zone_id', + 'environment_id', + 'name_checksum', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(NotificationcommandCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(NotificationcommandCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(NotificationcommandCustomvar::class); + + $relations->hasMany('notification', Notification::class); + $relations->hasMany('argument', NotificationcommandArgument::class); + $relations->hasMany('envvar', NotificationcommandEnvvar::class); + } +} diff --git a/library/Icingadb/Model/NotificationcommandArgument.php b/library/Icingadb/Model/NotificationcommandArgument.php new file mode 100644 index 0000000..e855022 --- /dev/null +++ b/library/Icingadb/Model/NotificationcommandArgument.php @@ -0,0 +1,75 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class NotificationcommandArgument extends Model +{ + public function getTableName() + { + return 'notificationcommand_argument'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'notificationcommand_id', + 'argument_key', + 'environment_id', + 'properties_checksum', + 'argument_value', + 'argument_order', + 'description', + 'argument_key_override', + 'repeat_key', + 'required', + 'set_if', + 'skip_key' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'notificationcommand_id' => t('Notificationcommand Id'), + 'argument_key' => t('Notificationcommand Argument Name'), + 'environment_id' => t('Environment Id'), + 'properties_checksum' => t('Notificationcommand Argument Properties Checksum'), + 'argument_value' => t('Notificationcommand Argument Value'), + 'argument_order' => t('Notificationcommand Argument Position'), + 'description' => t('Notificationcommand Argument Description'), + 'argument_key_override' => t('Notificationcommand Argument Actual Name'), + 'repeat_key' => t('Notificationcommand Argument Repeated'), + 'required' => t('Notificationcommand Argument Required'), + 'set_if' => t('Notificationcommand Argument Condition'), + 'skip_key' => t('Notificationcommand Argument Without Name') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'notificationcommand_id', + 'environment_id', + 'properties_checksum' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('notificationcommand', Notificationcommand::class); + } +} diff --git a/library/Icingadb/Model/NotificationcommandCustomvar.php b/library/Icingadb/Model/NotificationcommandCustomvar.php new file mode 100644 index 0000000..bd103f7 --- /dev/null +++ b/library/Icingadb/Model/NotificationcommandCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class NotificationcommandCustomvar extends Model +{ + public function getTableName() + { + return 'notificationcommand_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'notificationcommand_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'notificationcommand_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('notificationcommand', Notificationcommand::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/NotificationcommandEnvvar.php b/library/Icingadb/Model/NotificationcommandEnvvar.php new file mode 100644 index 0000000..09f77b0 --- /dev/null +++ b/library/Icingadb/Model/NotificationcommandEnvvar.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class NotificationcommandEnvvar extends Model +{ + public function getTableName() + { + return 'notificationcommand_envvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'notificationcommand_id', + 'envvar_key', + 'environment_id', + 'properties_checksum', + 'envvar_value' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'notificationcommand_id' => t('Notificationcommand Id'), + 'envvar_key' => t('Notificationcommand Envvar Key'), + 'environment_id' => t('Environment Id'), + 'properties_checksum' => t('Notificationcommand Envvar Properties Checksum'), + 'envvar_value' => t('Notificationcommand Envvar Value') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'notificationcommand_id', + 'environment_id', + 'properties_checksum', + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('notificationcommand', Notificationcommand::class); + } +} diff --git a/library/Icingadb/Model/Service.php b/library/Icingadb/Model/Service.php new file mode 100644 index 0000000..c57b6ba --- /dev/null +++ b/library/Icingadb/Model/Service.php @@ -0,0 +1,225 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Defaults; +use ipl\Orm\Model; +use ipl\Orm\Relations; +use ipl\Orm\ResultSet; + +class Service extends Model +{ + use Auth; + + public function getTableName() + { + return 'service'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'host_id', + 'name', + 'name_ci', + 'display_name', + 'checkcommand_name', + 'checkcommand_id', + 'max_check_attempts', + 'check_timeperiod_name', + 'check_timeperiod_id', + 'check_timeout', + 'check_interval', + 'check_retry_interval', + 'active_checks_enabled', + 'passive_checks_enabled', + 'event_handler_enabled', + 'notifications_enabled', + 'flapping_enabled', + 'flapping_threshold_low', + 'flapping_threshold_high', + 'perfdata_enabled', + 'eventcommand_name', + 'eventcommand_id', + 'is_volatile', + 'action_url_id', + 'notes_url_id', + 'notes', + 'icon_image_id', + 'icon_image_alt', + 'zone_name', + 'zone_id', + 'command_endpoint_name', + 'command_endpoint_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Service Name Checksum'), + 'properties_checksum' => t('Service Properties Checksum'), + 'host_id' => t('Host Id'), + 'name' => t('Service Name'), + 'name_ci' => t('Service Name (CI)'), + 'display_name' => t('Service Display Name'), + 'checkcommand_name' => t('Checkcommand Name'), + 'checkcommand_id' => t('Checkcommand Id'), + 'max_check_attempts' => t('Service Max Check Attempts'), + 'check_timeperiod_name' => t('Check Timeperiod Name'), + 'check_timeperiod_id' => t('Check Timeperiod Id'), + 'check_timeout' => t('Service Check Timeout'), + 'check_interval' => t('Service Check Interval'), + 'check_retry_interval' => t('Service Check Retry Inverval'), + 'active_checks_enabled' => t('Service Active Checks Enabled'), + 'passive_checks_enabled' => t('Service Passive Checks Enabled'), + 'event_handler_enabled' => t('Service Event Handler Enabled'), + 'notifications_enabled' => t('Service Notifications Enabled'), + 'flapping_enabled' => t('Service Flapping Enabled'), + 'flapping_threshold_low' => t('Service Flapping Threshold Low'), + 'flapping_threshold_high' => t('Service Flapping Threshold High'), + 'perfdata_enabled' => t('Service Performance Data Enabled'), + 'eventcommand_name' => t('Eventcommand Name'), + 'eventcommand_id' => t('Eventcommand Id'), + 'is_volatile' => t('Service Is Volatile'), + 'action_url_id' => t('Action Url Id'), + 'notes_url_id' => t('Notes Url Id'), + 'notes' => t('Service Notes'), + 'icon_image_id' => t('Icon Image Id'), + 'icon_image_alt' => t('Icon Image Alt'), + 'zone_name' => t('Zone Name'), + 'zone_id' => t('Zone Id'), + 'command_endpoint_name' => t('Endpoint Name'), + 'command_endpoint_id' => t('Endpoint Id') + ]; + } + + public function getSearchColumns() + { + return ['name_ci', 'display_name']; + } + + public function getDefaultSort() + { + return 'service.display_name'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'active_checks_enabled', + 'passive_checks_enabled', + 'event_handler_enabled', + 'notifications_enabled', + 'flapping_enabled', + 'is_volatile' + ])); + + $behaviors->add(new ReRoute([ + 'user' => 'notification.user', + 'usergroup' => 'notification.usergroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'host_id', + 'checkcommand_id', + 'check_timeperiod_id', + 'eventcommand_id', + 'action_url_id', + 'notes_url_id', + 'icon_image_id', + 'zone_id', + 'command_endpoint_id' + ])); + } + + public function createDefaults(Defaults $defaults) + { + $defaults->add('vars', function (self $subject) { + if (! $subject->customvar_flat instanceof ResultSet) { + $this->applyRestrictions($subject->customvar_flat); + } + + $vars = []; + foreach ($subject->customvar_flat as $customVar) { + $vars[$customVar->flatname] = $customVar->flatvalue; + } + + return $vars; + }); + + $defaults->add('customvars', function (self $subject) { + if (! $subject->customvar instanceof ResultSet) { + $this->applyRestrictions($subject->customvar); + } + + $vars = []; + foreach ($subject->customvar as $customVar) { + $vars[$customVar->name] = json_decode($customVar->value, true); + } + + return $vars; + }); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('host', Host::class)->setJoinType('LEFT'); + $relations->belongsTo('checkcommand', Checkcommand::class); + $relations->belongsTo('timeperiod', Timeperiod::class) + ->setCandidateKey('check_timeperiod_id') + ->setJoinType('LEFT'); + $relations->belongsTo('eventcommand', Eventcommand::class); + $relations->belongsTo('action_url', ActionUrl::class) + ->setCandidateKey('action_url_id') + ->setForeignKey('id'); + $relations->belongsTo('notes_url', NotesUrl::class) + ->setCandidateKey('notes_url_id') + ->setForeignKey('id'); + $relations->belongsTo('icon_image', IconImage::class) + ->setCandidateKey('icon_image_id') + ->setJoinType('LEFT'); + $relations->belongsTo('zone', Zone::class); + $relations->belongsTo('endpoint', Endpoint::class) + ->setCandidateKey('command_endpoint_id'); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(ServiceCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(ServiceCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(ServiceCustomvar::class); + $relations->belongsToMany('servicegroup', Servicegroup::class) + ->through(ServicegroupMember::class); + $relations->belongsToMany('hostgroup', Hostgroup::class) + ->through(HostgroupMember::class); + + $relations->hasOne('state', ServiceState::class)->setJoinType('LEFT'); + $relations->hasMany('comment', Comment::class)->setJoinType('LEFT'); + $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT'); + $relations->hasMany('history', History::class); + $relations->hasMany('notification', Notification::class)->setJoinType('LEFT'); + $relations->hasMany('notification_history', NotificationHistory::class); + } +} diff --git a/library/Icingadb/Model/ServiceCustomvar.php b/library/Icingadb/Model/ServiceCustomvar.php new file mode 100644 index 0000000..07ee84c --- /dev/null +++ b/library/Icingadb/Model/ServiceCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class ServiceCustomvar extends Model +{ + public function getTableName() + { + return 'service_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'service_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'service_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('service', Service::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setForeignKey('customvar_id') + ->setCandidateKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/ServiceState.php b/library/Icingadb/Model/ServiceState.php new file mode 100644 index 0000000..c5daa08 --- /dev/null +++ b/library/Icingadb/Model/ServiceState.php @@ -0,0 +1,76 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Common\ServiceStates; +use ipl\Orm\Relations; + +class ServiceState extends State +{ + public function getTableName() + { + return 'service_state'; + } + + public function getKeyName() + { + return 'service_id'; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'state_type' => t('Service State Type'), + 'soft_state' => t('Service Soft State'), + 'hard_state' => t('Service Hard State'), + 'previous_soft_state' => t('Service Previous Soft State'), + 'previous_hard_state' => t('Service Previous Hard State'), + 'check_attempt' => t('Service Check Attempt No.'), + 'severity' => t('Service State Severity'), + 'output' => t('Service Output'), + 'long_output' => t('Service Long Output'), + 'performance_data' => t('Service Performance Data'), + 'normalized_performance_data' => t('Service Normalized Performance Data'), + 'check_commandline' => t('Service Check Commandline'), + 'is_problem' => t('Service Has Problem'), + 'is_handled' => t('Service Is Handled'), + 'is_reachable' => t('Service Is Reachable'), + 'is_flapping' => t('Service Is Flapping'), + 'is_overdue' => t('Service Check Is Overdue'), + 'is_acknowledged' => t('Service Is Acknowledged'), + 'acknowledgement_comment_id' => t('Acknowledgement Comment Id'), + 'in_downtime' => t('Service In Downtime'), + 'execution_time' => t('Service Check Execution Time'), + 'latency' => t('Service Check Latency'), + 'check_timeout' => t('Service Check Timeout'), + 'check_source' => t('Service Check Source'), + 'last_update' => t('Service Last Update'), + 'last_state_change' => t('Service Last State Change'), + 'next_check' => t('Service Next Check'), + 'next_update' => t('Service Next Update') + ]; + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('service', Service::class); + $relations->hasOne('last_comment', LastServiceComment::class) + ->setCandidateKey('last_comment_id') + ->setForeignKey('id') + ->setJoinType('LEFT'); + } + + public function getStateText(): string + { + return ServiceStates::text($this->soft_state); + } + + public function getStateTextTranslated(): string + { + return ServiceStates::text($this->soft_state); + } +} diff --git a/library/Icingadb/Model/Servicegroup.php b/library/Icingadb/Model/Servicegroup.php new file mode 100644 index 0000000..0da92fb --- /dev/null +++ b/library/Icingadb/Model/Servicegroup.php @@ -0,0 +1,91 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Servicegroup extends Model +{ + public function getTableName() + { + return 'servicegroup'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'display_name', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Servicegroup Name Checksum'), + 'properties_checksum' => t('Servicegroup Properties Checksum'), + 'name' => t('Servicegroup Name'), + 'name_ci' => t('Servicegroup Name (CI)'), + 'display_name' => t('Servicegroup Display Name'), + 'zone_id' => t('Zone Id') + ]; + } + + public function getSearchColumns() + { + return ['name_ci', 'display_name']; + } + + public function getDefaultSort() + { + return 'display_name'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'host' => 'service.host', + 'hostgroup' => 'service.hostgroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'zone_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(ServicegroupCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(ServicegroupCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(ServicegroupCustomvar::class); + $relations->belongsToMany('service', Service::class) + ->through(ServicegroupMember::class); + } +} diff --git a/library/Icingadb/Model/ServicegroupCustomvar.php b/library/Icingadb/Model/ServicegroupCustomvar.php new file mode 100644 index 0000000..23e536b --- /dev/null +++ b/library/Icingadb/Model/ServicegroupCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class ServicegroupCustomvar extends Model +{ + public function getTableName() + { + return 'servicegroup_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'servicegroup_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'servicegroup_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('servicegroup', Servicegroup::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/ServicegroupMember.php b/library/Icingadb/Model/ServicegroupMember.php new file mode 100644 index 0000000..5da537a --- /dev/null +++ b/library/Icingadb/Model/ServicegroupMember.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class ServicegroupMember extends Model +{ + public function getTableName() + { + return 'servicegroup_member'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'service_id', + 'servicegroup_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'service_id', + 'servicegroup_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('servicegroup', Servicegroup::class); + $relations->belongsTo('service', Service::class); + } +} diff --git a/library/Icingadb/Model/ServicegroupSummary.php b/library/Icingadb/Model/ServicegroupSummary.php new file mode 100644 index 0000000..89a0953 --- /dev/null +++ b/library/Icingadb/Model/ServicegroupSummary.php @@ -0,0 +1,167 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Relations; +use ipl\Orm\UnionModel; +use ipl\Sql\Adapter\Pgsql; +use ipl\Sql\Connection; +use ipl\Sql\Expression; +use ipl\Sql\Select; + +class ServicegroupSummary extends UnionModel +{ + public static function on(Connection $db) + { + $q = parent::on($db); + + $q->on($q::ON_SELECT_ASSEMBLED, function (Select $select) use ($q) { + $model = $q->getModel(); + + $groupBy = $q->getResolver()->qualifyColumnsAndAliases((array) $model->getKeyName(), $model, false); + + // For PostgreSQL, ALL non-aggregate SELECT columns must appear in the GROUP BY clause: + if ($q->getDb()->getAdapter() instanceof Pgsql) { + /** + * Ignore Expressions, i.e. aggregate functions {@see getColumns()}, + * which do not need to be added to the GROUP BY. + */ + $candidates = array_filter($select->getColumns(), 'is_string'); + // Remove already considered columns for the GROUP BY, i.e. the primary key. + $candidates = array_diff_assoc($candidates, $groupBy); + $groupBy = array_merge($groupBy, $candidates); + } + + $select->groupBy($groupBy); + }); + + return $q; + } + + public function getTableName() + { + return 'servicegroup'; + } + + public function getKeyName() + { + return ['id' => 'servicegroup_id']; + } + + public function getColumns() + { + return [ + 'display_name' => 'servicegroup_display_name', + 'name' => 'servicegroup_name', + 'services_critical_handled' => new Expression( + 'SUM(CASE WHEN service_state = 2' + . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_critical_unhandled' => new Expression( + 'SUM(CASE WHEN service_state = 2' + . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_ok' => new Expression( + 'SUM(CASE WHEN service_state = 0 THEN 1 ELSE 0 END)' + ), + 'services_pending' => new Expression( + 'SUM(CASE WHEN service_state = 99 THEN 1 ELSE 0 END)' + ), + 'services_total' => new Expression( + 'SUM(CASE WHEN service_id IS NOT NULL THEN 1 ELSE 0 END)' + ), + 'services_unknown_handled' => new Expression( + 'SUM(CASE WHEN service_state = 3' + . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_unknown_unhandled' => new Expression( + 'SUM(CASE WHEN service_state = 3' + . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_warning_handled' => new Expression( + 'SUM(CASE WHEN service_state = 1' + . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_warning_unhandled' => new Expression( + 'SUM(CASE WHEN service_state = 1' + . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_severity' => new Expression('MAX(service_severity)') + ]; + } + + public function getSearchColumns() + { + return ['display_name']; + } + + public function getDefaultSort() + { + return 'display_name'; + } + + public function getUnions() + { + $unions = [ + [ + Service::class, + [ + 'servicegroup', + 'state' + ], + [ + 'servicegroup_id' => 'servicegroup.id', + 'servicegroup_name' => 'servicegroup.name', + 'servicegroup_display_name' => 'servicegroup.display_name', + 'service_id' => 'service.id', + 'service_state' => 'state.soft_state', + 'service_handled' => 'state.is_handled', + 'service_reachable' => 'state.is_reachable', + 'service_severity' => 'state.severity' + ] + ], + [ + Servicegroup::class, + [], + [ + 'servicegroup_id' => 'servicegroup.id', + 'servicegroup_name' => 'servicegroup.name', + 'servicegroup_display_name' => 'servicegroup.display_name', + 'service_id' => new Expression('NULL'), + 'service_state' => new Expression('NULL'), + 'service_handled' => new Expression('NULL'), + 'service_reachable' => new Expression('NULL'), + 'service_severity' => new Expression('0') + ] + ] + ]; + + return $unions; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id' + ])); + + // This is because there is no better way + (new Servicegroup())->createBehaviors($behaviors); + } + + public function createRelations(Relations $relations) + { + // This is because there is no better way + (new Servicegroup())->createRelations($relations); + } + + public function getColumnDefinitions() + { + // This is because there is no better way + return (new Servicegroup())->getColumnDefinitions(); + } +} diff --git a/library/Icingadb/Model/ServicestateSummary.php b/library/Icingadb/Model/ServicestateSummary.php new file mode 100644 index 0000000..b1364f7 --- /dev/null +++ b/library/Icingadb/Model/ServicestateSummary.php @@ -0,0 +1,99 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Sql\Connection; +use ipl\Sql\Expression; + +class ServicestateSummary extends Service +{ + public function getSummaryColumns() + { + return [ + 'services_acknowledged' => new Expression( + 'SUM(CASE WHEN service_state.is_acknowledged = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_active_checks_enabled' => new Expression( + 'SUM(CASE WHEN service.active_checks_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_passive_checks_enabled' => new Expression( + 'SUM(CASE WHEN service.passive_checks_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_critical_handled' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 2' + . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_critical_unhandled' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 2' + . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_event_handler_enabled' => new Expression( + 'SUM(CASE WHEN service.event_handler_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_flapping_enabled' => new Expression( + 'SUM(CASE WHEN service.flapping_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_notifications_enabled' => new Expression( + 'SUM(CASE WHEN service.notifications_enabled = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_ok' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 0 THEN 1 ELSE 0 END)' + ), + 'services_pending' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 99 THEN 1 ELSE 0 END)' + ), + 'services_problems_unacknowledged' => new Expression( + 'SUM(CASE WHEN service_state.is_problem = \'y\'' + . ' AND service_state.is_acknowledged = \'n\' THEN 1 ELSE 0 END)' + ), + 'services_total' => new Expression( + 'SUM(CASE WHEN service.id IS NOT NULL THEN 1 ELSE 0 END)' + ), + 'services_unknown_handled' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 3' + . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_unknown_unhandled' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 3' + . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)' + ), + 'services_warning_handled' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 1' + . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)' + ), + 'services_warning_unhandled' => new Expression( + 'SUM(CASE WHEN service_state.soft_state = 1' + . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)' + ) + ]; + } + + public static function on(Connection $db) + { + $q = parent::on($db); + $q->utilize('state'); + + /** @var static $m */ + $m = $q->getModel(); + $q->columns($m->getSummaryColumns()); + + return $q; + } + + public function getColumns() + { + return array_merge(parent::getColumns(), $this->getSummaryColumns()); + } + + public function getDefaultSort() + { + return null; + } + + public function getSearchColumns() + { + return ['name_ci', 'host.name_ci']; + } +} diff --git a/library/Icingadb/Model/State.php b/library/Icingadb/Model/State.php new file mode 100644 index 0000000..2d242a8 --- /dev/null +++ b/library/Icingadb/Model/State.php @@ -0,0 +1,177 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use DateTime; +use Icinga\Module\Icingadb\Common\Icons; +use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Web\Widget\Icon; + +/** + * Base class for the {@link HostState} and {@link ServiceState} models providing common columns. + * + * @property string $environment_id The environment id + * @property string $state_type The state type (hard or soft) + * @property int $soft_state The current soft state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN) + * @property int $hard_state The current hard state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN) + * @property int $previous_soft_state The previous soft state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN) + * @property int $previous_hard_state The previous hard state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN) + * @property int $check_attempt The check attempt count + * @property int $severity The calculated severity + * @property ?string $output The check output + * @property ?string $long_output The long check output + * @property ?string $performance_data The performance data + * @property ?string $normalized_performance_data The normalized performance data (converted ms to s, GiB to byte etc.) + * @property ?string $check_commandline The executed check command + * @property bool $is_problem Whether in non-OK state + * @property bool $is_handled Whether the state is handled + * @property bool $is_reachable Whether the node is reachable + * @property bool $is_flapping Whether the state is flapping + * @property bool $is_overdue Whether the check is overdue + * @property bool|string $is_acknowledged Whether the state is acknowledged (bool), can also be `sticky` (string) + * @property ?string $acknowledgement_comment_id The id of acknowledgement comment + * @property ?string $last_comment_id The id of last comment + * @property bool $in_downtime Whether the node is in downtime + * @property ?int $execution_time The check execution time + * @property ?int $latency The check latency + * @property ?int $check_timeout The check timeout + * @property ?string $check_source The name of the node that executes the check + * @property ?string $scheduling_source The name of the node that schedules the check + * @property ?DateTime $last_update The time when the node was last updated + * @property ?DateTime $last_state_change The time when the node last got a status change + * @property ?DateTime $next_check The time when the node will execute the next check + * @property ?DateTime $next_update The time when the next check of the node is expected to end + */ +abstract class State extends Model +{ + /** + * Get the state as the textual representation + * + * @return string + */ + abstract public function getStateText(): string; + + /** + * Get the state as the translated textual representation + * + * @return string + */ + abstract public function getStateTextTranslated(): string; + + public function getColumns() + { + return [ + 'environment_id', + 'state_type', + 'soft_state', + 'hard_state', + 'previous_soft_state', + 'previous_hard_state', + 'check_attempt', + 'severity', + 'output', + 'long_output', + 'performance_data', + 'normalized_performance_data', + 'check_commandline', + 'is_problem', + 'is_handled', + 'is_reachable', + 'is_flapping', + 'is_overdue', + 'is_acknowledged', + 'acknowledgement_comment_id', + 'last_comment_id', + 'in_downtime', + 'execution_time', + 'latency', + 'check_timeout', + 'check_source', + 'scheduling_source', + 'last_update', + 'last_state_change', + 'next_check', + 'next_update' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new BoolCast([ + 'is_problem', + 'is_handled', + 'is_reachable', + 'is_flapping', + 'is_overdue', + 'is_acknowledged', + 'in_downtime' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'last_update', + 'last_state_change', + 'next_check', + 'next_update' + ])); + + $behaviors->add(new Binary([ + $this->getKeyName(), + 'environment_id', + 'acknowledgement_comment_id', + 'last_comment_id' + ])); + } + + /** + * Get the state icon + * + * @return Icon|null + */ + public function getIcon(): ?Icon + { + $icon = null; + switch (true) { + case $this->is_acknowledged: + $icon = new Icon(Icons::IS_ACKNOWLEDGED); + + break; + case $this->in_downtime: + $icon = new Icon( + Icons::IN_DOWNTIME, + ['title' => sprintf( + '%s (%s)', + strtoupper($this->getStateTextTranslated()), + $this->is_handled ? t('handled by Downtime') : t('in Downtime') + )] + ); + + break; + case $this->is_flapping: + $icon = new Icon(Icons::IS_FLAPPING); + + break; + case ! $this->is_reachable: + $icon = new Icon(Icons::HOST_DOWN, [ + 'title' => sprintf( + '%s (%s)', + strtoupper($this->getStateTextTranslated()), + t('is unreachable') + ) + ]); + + break; + case $this->is_handled: + $icon = new Icon(Icons::HOST_DOWN); + + break; + } + + return $icon; + } +} diff --git a/library/Icingadb/Model/StateHistory.php b/library/Icingadb/Model/StateHistory.php new file mode 100644 index 0000000..9d80cb2 --- /dev/null +++ b/library/Icingadb/Model/StateHistory.php @@ -0,0 +1,101 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +/** + * Model for table `state_history` + * + * Please note that using this model will fetch history entries for decommissioned services. To avoid this, + * the query needs a `state_history.service_id IS NULL OR state_history_service.id IS NOT NULL` where. + */ +class StateHistory extends Model +{ + public function getTableName() + { + return 'state_history'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'endpoint_id', + 'object_type', + 'host_id', + 'service_id', + 'event_time', + 'state_type', + 'soft_state', + 'hard_state', + 'check_attempt', + 'previous_soft_state', + 'previous_hard_state', + 'output', + 'long_output', + 'max_check_attempts', + 'check_source', + 'scheduling_source' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'endpoint_id' => t('Endpoint Id'), + 'object_type' => t('Object Type'), + 'host_id' => t('Host Id'), + 'service_id' => t('Service Id'), + 'event_time' => t('Event Time'), + 'state_type' => t('Event State Type'), + 'soft_state' => t('Event Soft State'), + 'hard_state' => t('Event Hard State'), + 'check_attempt' => t('Event Check Attempt No.'), + 'previous_soft_state' => t('Event Previous Soft State'), + 'previous_hard_state' => t('Event Previous Hard State'), + 'output' => t('Event Output'), + 'long_output' => t('Event Long Output'), + 'max_check_attempts' => t('Event Max Check Attempts'), + 'check_source' => t('Event Check Source') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new MillisecondTimestamp([ + 'event_time' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('endpoint', Endpoint::class); + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('history', History::class) + ->setCandidateKey('id') + ->setForeignKey('state_history_id'); + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class)->setJoinType('LEFT'); + } +} diff --git a/library/Icingadb/Model/Timeperiod.php b/library/Icingadb/Model/Timeperiod.php new file mode 100644 index 0000000..26dd722 --- /dev/null +++ b/library/Icingadb/Model/Timeperiod.php @@ -0,0 +1,91 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Timeperiod extends Model +{ + public function getTableName() + { + return 'timeperiod'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'display_name', + 'prefer_includes', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Timeperiod Name Checksum'), + 'properties_checksum' => t('Timeperiod Properties Checksum'), + 'name' => t('Timeperiod Name'), + 'name_ci' => t('Timeperiod Name (CI)'), + 'display_name' => t('Timeperiod Display Name'), + 'prefer_includes' => t('Timeperiod Prefer Includes'), + 'zone_id' => t('Zone Id') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'hostgroup' => 'host.hostgroup', + 'servicegroup' => 'service.servicegroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'zone_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(TimeperiodCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(TimeperiodCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(TimeperiodCustomvar::class); + + // TODO: Decide how to establish the override relations + + $relations->hasMany('range', TimeperiodRange::class); + $relations->hasMany('host', Host::class) + ->setForeignKey('check_timeperiod_id'); + $relations->hasMany('Notification', Notification::class); + $relations->hasMany('service', Service::class) + ->setForeignKey('check_timeperiod_id'); + $relations->hasMany('user', User::class); + } +} diff --git a/library/Icingadb/Model/TimeperiodCustomvar.php b/library/Icingadb/Model/TimeperiodCustomvar.php new file mode 100644 index 0000000..614a312 --- /dev/null +++ b/library/Icingadb/Model/TimeperiodCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class TimeperiodCustomvar extends Model +{ + public function getTableName() + { + return 'timeperiod_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'timeperiod_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'timeperiod_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('timeperiod', Timeperiod::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/TimeperiodOverrideExclude.php b/library/Icingadb/Model/TimeperiodOverrideExclude.php new file mode 100644 index 0000000..c33df77 --- /dev/null +++ b/library/Icingadb/Model/TimeperiodOverrideExclude.php @@ -0,0 +1,51 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class TimeperiodOverrideExclude extends Model +{ + public function getTableName() + { + return 'timeperiod_override_exclude'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'timeperiod_id', + 'override_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'timeperiod_id', + 'override_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('timeperiod', Timeperiod::class); + // TODO: `timeperiod` cannot be used again, find a better name + $relations->belongsTo('timeperiod', Timeperiod::class) + ->setCandidateKey('override_id'); + } +} diff --git a/library/Icingadb/Model/TimeperiodOverrideInclude.php b/library/Icingadb/Model/TimeperiodOverrideInclude.php new file mode 100644 index 0000000..5418596 --- /dev/null +++ b/library/Icingadb/Model/TimeperiodOverrideInclude.php @@ -0,0 +1,51 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class TimeperiodOverrideInclude extends Model +{ + public function getTableName() + { + return 'timeperiod_override_include'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'timeperiod_id', + 'override_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'timeperiod_id', + 'override_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('timeperiod', Timeperiod::class); + // TODO: `timeperiod` cannot be used again, find a better name + $relations->belongsTo('timeperiod', Timeperiod::class) + ->setCandidateKey('override_id'); + } +} diff --git a/library/Icingadb/Model/TimeperiodRange.php b/library/Icingadb/Model/TimeperiodRange.php new file mode 100644 index 0000000..62e87f8 --- /dev/null +++ b/library/Icingadb/Model/TimeperiodRange.php @@ -0,0 +1,58 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class TimeperiodRange extends Model +{ + public function getTableName() + { + return 'timeperiod_range'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'timeperiod_id', + 'range_key', + 'environment_id', + 'range_value' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'timeperiod_id' => t('Timeperiod Id'), + 'range_key' => t('Timeperiod Range Date(s)/Day'), + 'environment_id' => t('Environment Id'), + 'range_value' => t('Timeperiod Range Time') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'timeperiod_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('timeperiod', Timeperiod::class); + } +} diff --git a/library/Icingadb/Model/User.php b/library/Icingadb/Model/User.php new file mode 100644 index 0000000..91d0d71 --- /dev/null +++ b/library/Icingadb/Model/User.php @@ -0,0 +1,134 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\Bitmask; +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class User extends Model +{ + public function getTableName() + { + return 'user'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'display_name', + 'email', + 'pager', + 'notifications_enabled', + 'timeperiod_id', + 'states', + 'types', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('User Name Checksum'), + 'properties_checksum' => t('User Properties Checksum'), + 'name' => t('User Name'), + 'name_ci' => t('User Name (CI)'), + 'display_name' => t('User Display Name'), + 'email' => t('User Email'), + 'pager' => t('User Pager'), + 'notifications_enabled' => t('User Receives Notifications'), + 'timeperiod_id' => t('Timeperiod Id'), + 'states' => t('Notification State Filter'), + 'types' => t('Notification Type Filter'), + 'zone_id' => t('Zone Id') + ]; + } + + public function getSearchColumns() + { + return ['name_ci', 'display_name']; + } + + public function getDefaultSort() + { + return 'user.display_name'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'host' => 'notification.host', + 'service' => 'notification.service', + 'hostgroup' => 'notification.host.hostgroup', + 'servicegroup' => 'notification.service.servicegroup' + ])); + + $behaviors->add(new Bitmask([ + 'states' => [ + 'ok' => 1, + 'warning' => 2, + 'critical' => 4, + 'unknown' => 8, + 'up' => 16, + 'down' => 32 + ], + 'types' => [ + 'downtime_start' => 1, + 'downtime_end' => 2, + 'downtime_removed' => 4, + 'custom' => 8, + 'ack' => 16, + 'problem' => 32, + 'recovery' => 64, + 'flapping_start' => 128, + 'flapping_end' => 256 + ] + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'timeperiod_id', + 'zone_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('timeperiod', Timeperiod::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(UserCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(UserCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(UserCustomvar::class); + $relations->belongsToMany('notification', Notification::class) + ->through('notification_recipient'); + $relations->belongsToMany('notification_history', NotificationHistory::class) + ->through('user_notification_history'); + $relations->belongsToMany('usergroup', Usergroup::class) + ->through(UsergroupMember::class); + } +} diff --git a/library/Icingadb/Model/UserCustomvar.php b/library/Icingadb/Model/UserCustomvar.php new file mode 100644 index 0000000..a702b68 --- /dev/null +++ b/library/Icingadb/Model/UserCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class UserCustomvar extends Model +{ + public function getTableName() + { + return 'user_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'user_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'user_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('user', User::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/Usergroup.php b/library/Icingadb/Model/Usergroup.php new file mode 100644 index 0000000..34b0647 --- /dev/null +++ b/library/Icingadb/Model/Usergroup.php @@ -0,0 +1,95 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Usergroup extends Model +{ + public function getTableName() + { + return 'usergroup'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'display_name', + 'zone_id' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Usergroup Name Checksum'), + 'properties_checksum' => t('Usergroup Properties Checksum'), + 'name' => t('Usergroup Name'), + 'name_ci' => t('Usergroup Name (CI)'), + 'display_name' => t('Usergroup Display Name'), + 'zone_id' => t('Zone Id') + ]; + } + + public function getSearchColumns() + { + return ['name_ci', 'display_name']; + } + + public function getDefaultSort() + { + return 'usergroup.display_name'; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new ReRoute([ + 'host' => 'notification.host', + 'service' => 'notification.service', + 'hostgroup' => 'notification.host.hostgroup', + 'servicegroup' => 'notification.service.servicegroup' + ])); + + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'zone_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('zone', Zone::class); + + $relations->belongsToMany('customvar', Customvar::class) + ->through(UsergroupCustomvar::class); + $relations->belongsToMany('customvar_flat', CustomvarFlat::class) + ->through(UsergroupCustomvar::class); + $relations->belongsToMany('vars', Vars::class) + ->through(UsergroupCustomvar::class); + $relations->belongsToMany('user', User::class) + ->through(UsergroupMember::class); + $relations->belongsToMany('notification', Notification::class) + ->through('notification_recipient'); + } +} diff --git a/library/Icingadb/Model/UsergroupCustomvar.php b/library/Icingadb/Model/UsergroupCustomvar.php new file mode 100644 index 0000000..ab97273 --- /dev/null +++ b/library/Icingadb/Model/UsergroupCustomvar.php @@ -0,0 +1,52 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class UsergroupCustomvar extends Model +{ + public function getTableName() + { + return 'usergroup_customvar'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'usergroup_id', + 'customvar_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'usergroup_id', + 'customvar_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('usergroup', Usergroup::class); + $relations->belongsTo('customvar', Customvar::class); + $relations->belongsTo('customvar_flat', CustomvarFlat::class) + ->setCandidateKey('customvar_id') + ->setForeignKey('customvar_id'); + } +} diff --git a/library/Icingadb/Model/UsergroupMember.php b/library/Icingadb/Model/UsergroupMember.php new file mode 100644 index 0000000..7c61d67 --- /dev/null +++ b/library/Icingadb/Model/UsergroupMember.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class UsergroupMember extends Model +{ + public function getTableName() + { + return 'usergroup_member'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'user_id', + 'usergroup_id', + 'environment_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'user_id', + 'usergroup_id', + 'environment_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + $relations->belongsTo('usergroup', Usergroup::class); + $relations->belongsTo('user', User::class); + } +} diff --git a/library/Icingadb/Model/Vars.php b/library/Icingadb/Model/Vars.php new file mode 100644 index 0000000..304d526 --- /dev/null +++ b/library/Icingadb/Model/Vars.php @@ -0,0 +1,28 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use Icinga\Module\Icingadb\Model\Behavior\FlattenedObjectVars; +use ipl\Orm\Behaviors; +use ipl\Sql\Connection; + +class Vars extends CustomvarFlat +{ + /** + * @internal Don't use. This model acts only as relation target and is not supposed to be directly used as query + * target. Use {@see CustomvarFlat} instead. + */ + public static function on(Connection $_) + { + throw new \LogicException('Documentation says: DO NOT USE. Can\'t you read?'); + } + + public function createBehaviors(Behaviors $behaviors) + { + parent::createBehaviors($behaviors); + + $behaviors->add(new FlattenedObjectVars()); + } +} diff --git a/library/Icingadb/Model/Zone.php b/library/Icingadb/Model/Zone.php new file mode 100644 index 0000000..aaf3bbf --- /dev/null +++ b/library/Icingadb/Model/Zone.php @@ -0,0 +1,82 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Model; + +use ipl\Orm\Behavior\Binary; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; +use ipl\Orm\Relations; + +class Zone extends Model +{ + public function getTableName() + { + return 'zone'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns() + { + return [ + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'name', + 'name_ci', + 'is_global', + 'parent_id', + 'depth' + ]; + } + + public function getColumnDefinitions() + { + return [ + 'environment_id' => t('Environment Id'), + 'name_checksum' => t('Zone Name Checksum'), + 'properties_checksum' => t('Zone Properties Checksum'), + 'name' => t('Zone Name'), + 'name_ci' => t('Zone Name (CI)'), + 'is_global' => t('Zone Is Global'), + 'parent_id' => t('Parent Zone Id'), + 'depth' => t('Zone Depth') + ]; + } + + public function createBehaviors(Behaviors $behaviors) + { + $behaviors->add(new Binary([ + 'id', + 'environment_id', + 'name_checksum', + 'properties_checksum', + 'parent_id' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('environment', Environment::class); + + $relations->hasMany('comment', Comment::class); + $relations->hasMany('downtime', Downtime::class); + $relations->hasMany('endpoint', Endpoint::class); + $relations->hasMany('eventcommand', Eventcommand::class); + $relations->hasMany('host', Host::class); + $relations->hasMany('hostgroup', Hostgroup::class); + $relations->hasMany('notification', Notification::class); + $relations->hasMany('service', Service::class); + $relations->hasMany('servicegroup', Servicegroup::class); + $relations->hasMany('timeperiod', Timeperiod::class); + $relations->hasMany('user', User::class); + $relations->hasMany('usergroup', Usergroup::class); + + // TODO: Decide how to establish recursive relations + } +} diff --git a/library/Icingadb/ProvidedHook/ApplicationState.php b/library/Icingadb/ProvidedHook/ApplicationState.php new file mode 100644 index 0000000..8c7b008 --- /dev/null +++ b/library/Icingadb/ProvidedHook/ApplicationState.php @@ -0,0 +1,111 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\ProvidedHook; + +use Exception; +use Icinga\Application\Hook\ApplicationStateHook; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\IcingaRedis; +use Icinga\Module\Icingadb\Model\Instance; +use Icinga\Web\Session; +use ipl\Stdlib\Filter; + +class ApplicationState extends ApplicationStateHook +{ + use Database; + + public function collectMessages() + { + try { + $lastIcingaHeartbeat = IcingaRedis::getLastIcingaHeartbeat(); + } catch (Exception $e) { + $downSince = Session::getSession()->getNamespace('icingadb')->get('redis.down-since'); + + if ($downSince === null) { + $downSince = time(); + Session::getSession()->getNamespace('icingadb')->set('redis.down-since', $downSince); + } + + $this->addError( + 'icingadb/redis-down', + $downSince, + sprintf(t("Can't connect to Icinga Redis: %s"), $e->getMessage()) + ); + + return; + } + + $instance = Instance::on($this->getDb()) + ->with(['endpoint']) + ->filter(Filter::equal('responsible', true)) + ->orderBy('heartbeat', 'desc') + ->first(); + + if ($instance === null) { + $noInstanceSince = Session::getSession() + ->getNamespace('icingadb')->get('icingadb.no-instance-since'); + + if ($noInstanceSince === null) { + $noInstanceSince = time(); + Session::getSession() + ->getNamespace('icingadb')->set('icingadb.no-instance-since', $noInstanceSince); + } + + $this->addError( + 'icingadb/no-instance', + $noInstanceSince, + t( + 'It seems that Icinga DB is not running.' + . ' Make sure Icinga DB is running and writing into the database.' + ) + ); + + return; + } else { + Session::getSession()->getNamespace('icingadb')->delete('db.no-instance-since'); + } + + $outdatedDbHeartbeat = $instance->heartbeat->getTimestamp() < time() - 60; + + if ($lastIcingaHeartbeat === null) { + $missingSince = Session::getSession() + ->getNamespace('icingadb')->get('redis.heartbeat-missing-since'); + + if ($missingSince === null) { + $missingSince = time(); + Session::getSession() + ->getNamespace('icingadb')->set('redis.heartbeat-missing-since', $missingSince); + } + + $lastIcingaHeartbeat = $missingSince; + } else { + Session::getSession()->getNamespace('icingadb')->delete('redis.heartbeat-missing-since'); + } + + switch (true) { + case $outdatedDbHeartbeat && $instance->heartbeat->getTimestamp() > $lastIcingaHeartbeat: + $this->addError( + 'icingadb/redis-outdated', + $lastIcingaHeartbeat, + t('Icinga Redis is outdated. Make sure Icinga 2 is running and connected to Redis.') + ); + + break; + case $outdatedDbHeartbeat: + $this->addError( + 'icingadb/icingadb-down', + $instance->heartbeat->getTimestamp(), + t( + 'It seems that Icinga DB is not running.' + . ' Make sure Icinga DB is running and writing into the database.' + ) + ); + + break; + } + + Session::getSession()->getNamespace('icingadb')->delete('redis.down-since'); + } +} diff --git a/library/Icingadb/ProvidedHook/CreateHostSlaReport.php b/library/Icingadb/ProvidedHook/CreateHostSlaReport.php new file mode 100644 index 0000000..83ed911 --- /dev/null +++ b/library/Icingadb/ProvidedHook/CreateHostSlaReport.php @@ -0,0 +1,37 @@ +<?php + +namespace Icinga\Module\Icingadb\ProvidedHook; + +use Icinga\Authentication\Auth; +use Icinga\Module\Icingadb\Hook\HostActionsHook; +use Icinga\Module\Icingadb\Model\Host; +use ipl\I18n\Translation; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; + +class CreateHostSlaReport extends HostActionsHook +{ + use Translation; + + public function getActionsForObject(Host $host): array + { + if (! Auth::getInstance()->hasPermission('reporting/reports')) { + return []; + } + + $filter = QueryString::render(Filter::equal('host.name', $host->name)); + + return [ + new Link( + $this->translate('Create Host SLA Report'), + Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'host']), + [ + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ] + ) + ]; + } +} diff --git a/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php b/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php new file mode 100644 index 0000000..6da9fca --- /dev/null +++ b/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php @@ -0,0 +1,39 @@ +<?php + +namespace Icinga\Module\Icingadb\ProvidedHook; + +use Icinga\Authentication\Auth; +use Icinga\Module\Icingadb\Hook\HostsDetailExtensionHook; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\ValidHtml; +use ipl\I18n\Translation; +use ipl\Orm\Query; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; + +class CreateHostsSlaReport extends HostsDetailExtensionHook +{ + use Translation; + + public function getHtmlForObjects(Query $hosts): ValidHtml + { + if (Auth::getInstance()->hasPermission('reporting/reports')) { + $filter = QueryString::render($this->getBaseFilter()); + + return (new HtmlDocument()) + ->addHtml(Html::tag('h2', $this->translate('Reporting'))) + ->addHtml(new Link( + $this->translate('Create Host SLA Report'), + Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'host']), + [ + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ] + )); + } + + return new HtmlDocument(); + } +} diff --git a/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php b/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php new file mode 100644 index 0000000..eeab603 --- /dev/null +++ b/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Icingadb\ProvidedHook; + +use Icinga\Authentication\Auth; +use Icinga\Module\Icingadb\Hook\ServiceActionsHook; +use Icinga\Module\Icingadb\Model\Service; +use ipl\I18n\Translation; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; + +class CreateServiceSlaReport extends ServiceActionsHook +{ + use Translation; + + public function getActionsForObject(Service $service): array + { + if (! Auth::getInstance()->hasPermission('reporting/reports')) { + return []; + } + + $filter = QueryString::render(Filter::all( + Filter::equal('service.name', $service->name), + Filter::equal('host.name', $service->host->name) + )); + + return [ + new Link( + $this->translate('Create Service SLA Report'), + Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'service']), + [ + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ] + ) + ]; + } +} diff --git a/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php b/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php new file mode 100644 index 0000000..a65b54e --- /dev/null +++ b/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php @@ -0,0 +1,38 @@ +<?php + +namespace Icinga\Module\Icingadb\ProvidedHook; + +use Icinga\Authentication\Auth; +use Icinga\Module\Icingadb\Hook\ServicesDetailExtensionHook; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\ValidHtml; +use ipl\I18n\Translation; +use ipl\Orm\Query; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; + +class CreateServicesSlaReport extends ServicesDetailExtensionHook +{ + use Translation; + + public function getHtmlForObjects(Query $services): ValidHtml + { + if (Auth::getInstance()->hasPermission('reporting/reports')) { + $filter = QueryString::render($this->getBaseFilter()); + return (new HtmlDocument()) + ->addHtml(Html::tag('h2', $this->translate('Reporting'))) + ->addHtml(new Link( + $this->translate('Create Service SLA Report'), + Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'service']), + [ + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ] + )); + } + + return new HtmlDocument(); + } +} diff --git a/library/Icingadb/ProvidedHook/IcingaHealth.php b/library/Icingadb/ProvidedHook/IcingaHealth.php new file mode 100644 index 0000000..54e22c7 --- /dev/null +++ b/library/Icingadb/ProvidedHook/IcingaHealth.php @@ -0,0 +1,115 @@ +<?php + +// Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 + +namespace Icinga\Module\Icingadb\ProvidedHook; + +use Icinga\Application\Hook\HealthHook; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Model\Instance; +use ipl\Web\Url; + +class IcingaHealth extends HealthHook +{ + use Database; + + /** @var Instance */ + protected $instance; + + public function getName(): string + { + return 'Icinga DB'; + } + + public function getUrl(): Url + { + return Url::fromPath('icingadb/health'); + } + + public function checkHealth() + { + $instance = $this->getInstance(); + + if ($instance === null) { + $this->setState(self::STATE_UNKNOWN); + $this->setMessage(t( + 'Icinga DB is not running or not writing into the database' + . ' (make sure the icinga feature "icingadb" is enabled)' + )); + } elseif ($instance->heartbeat->getTimestamp() < time() - 60) { + $this->setState(self::STATE_CRITICAL); + $this->setMessage(t( + 'Icinga DB is not running or not writing into the database' + . ' (make sure the icinga feature "icingadb" is enabled)' + )); + } else { + $this->setState(self::STATE_OK); + $this->setMessage(t('Icinga DB is running and writing into the database')); + $warningMessages = []; + + if (! $instance->icinga2_active_host_checks_enabled) { + $this->setState(self::STATE_WARNING); + $warningMessages[] = t('Active host checks are disabled'); + } + + if (! $instance->icinga2_active_service_checks_enabled) { + $this->setState(self::STATE_WARNING); + $warningMessages[] = t('Active service checks are disabled'); + } + + if (! $instance->icinga2_notifications_enabled) { + $this->setState(self::STATE_WARNING); + $warningMessages[] = t('Notifications are disabled'); + } + + if ($this->getState() === self::STATE_WARNING) { + $this->setMessage(implode("; ", $warningMessages)); + } + } + + if ($instance !== null) { + $this->setMetrics([ + 'heartbeat' => $instance->heartbeat->getTimestamp(), + 'responsible' => $instance->responsible, + 'icinga2_active_host_checks_enabled' => $instance->icinga2_active_host_checks_enabled, + 'icinga2_active_service_checks_enabled' => $instance->icinga2_active_service_checks_enabled, + 'icinga2_event_handlers_enabled' => $instance->icinga2_event_handlers_enabled, + 'icinga2_flap_detection_enabled' => $instance->icinga2_flap_detection_enabled, + 'icinga2_notifications_enabled' => $instance->icinga2_notifications_enabled, + 'icinga2_performance_data_enabled' => $instance->icinga2_performance_data_enabled, + 'icinga2_start_time' => $instance->icinga2_start_time->getTimestamp(), + 'icinga2_version' => $instance->icinga2_version, + 'endpoint' => ['name' => $instance->endpoint->name] + ]); + } + } + + /** + * Get an Icinga DB instance + * + * @return ?Instance + */ + protected function getInstance() + { + if ($this->instance === null) { + $this->instance = Instance::on($this->getDb()) + ->with('endpoint') + ->columns([ + 'heartbeat', + 'responsible', + 'icinga2_active_host_checks_enabled', + 'icinga2_active_service_checks_enabled', + 'icinga2_event_handlers_enabled', + 'icinga2_flap_detection_enabled', + 'icinga2_notifications_enabled', + 'icinga2_performance_data_enabled', + 'icinga2_start_time', + 'icinga2_version', + 'endpoint.name' + ]) + ->first(); + } + + return $this->instance; + } +} diff --git a/library/Icingadb/ProvidedHook/RedisHealth.php b/library/Icingadb/ProvidedHook/RedisHealth.php new file mode 100644 index 0000000..1471aba --- /dev/null +++ b/library/Icingadb/ProvidedHook/RedisHealth.php @@ -0,0 +1,55 @@ +<?php + +// Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 + +namespace Icinga\Module\Icingadb\ProvidedHook; + +use Exception; +use Icinga\Application\Hook\HealthHook; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\IcingaRedis; +use Icinga\Module\Icingadb\Model\Instance; + +class RedisHealth extends HealthHook +{ + use Database; + + public function getName(): string + { + return 'Icinga Redis'; + } + + public function checkHealth() + { + try { + $lastIcingaHeartbeat = IcingaRedis::getLastIcingaHeartbeat(); + if ($lastIcingaHeartbeat === null) { + $lastIcingaHeartbeat = time(); + } + + $instance = Instance::on($this->getDb())->columns('heartbeat')->first(); + + if ($instance === null) { + $this->setState(self::STATE_UNKNOWN); + $this->setMessage(t( + 'Can\'t check Icinga Redis: Icinga DB is not running or not writing into the database' + . ' (make sure the icinga feature "icingadb" is enabled)' + )); + + return; + } + + $outdatedDbHeartbeat = $instance->heartbeat->getTimestamp() < time() - 60; + if (! $outdatedDbHeartbeat || $instance->heartbeat->getTimestamp() <= $lastIcingaHeartbeat) { + $this->setState(self::STATE_OK); + $this->setMessage(t('Icinga Redis available and up to date.')); + } elseif ($instance->heartbeat->getTimestamp() > $lastIcingaHeartbeat) { + $this->setState(self::STATE_CRITICAL); + $this->setMessage(t('Icinga Redis outdated. Make sure Icinga 2 is running and connected to Redis.')); + } + } catch (Exception $e) { + $this->setState(self::STATE_CRITICAL); + $this->setMessage(sprintf(t("Can't connect to Icinga Redis: %s"), $e->getMessage())); + } + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php new file mode 100644 index 0000000..d9c4f4f --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php @@ -0,0 +1,68 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; + +use Icinga\Application\Icinga; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Reporting\ReportData; +use Icinga\Module\Reporting\ReportRow; +use Icinga\Module\Reporting\Timerange; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter\Rule; + +use function ipl\I18n\t; + +class HostSlaReport extends SlaReport +{ + public function getName() + { + $name = t('Host SLA'); + if (Icinga::app()->getModuleManager()->hasEnabled('idoreports')) { + $name .= ' (Icinga DB)'; + } + + return $name; + } + + protected function createReportData() + { + return (new ReportData()) + ->setDimensions([t('Hostname')]) + ->setValues([t('SLA in %')]); + } + + protected function createReportRow($row) + { + if ($row->sla === null) { + return null; + } + + return (new ReportRow()) + ->setDimensions([$row->display_name]) + ->setValues([(float) $row->sla]); + } + + protected function fetchSla(Timerange $timerange, Rule $filter = null) + { + $sla = Host::on($this->getDb()) + ->columns([ + 'display_name', + 'sla' => new Expression(sprintf( + "get_sla_ok_percent(%s, NULL, '%s', '%s')", + 'host.id', + $timerange->getStart()->format('Uv'), + $timerange->getEnd()->format('Uv') + )) + ]); + + $this->applyRestrictions($sla); + + if ($filter !== null) { + $sla->filter($filter); + } + + return $sla; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php new file mode 100644 index 0000000..46a0684 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php @@ -0,0 +1,72 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; + +use Icinga\Application\Icinga; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Module\Reporting\ReportData; +use Icinga\Module\Reporting\ReportRow; +use Icinga\Module\Reporting\Timerange; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter\Rule; + +use function ipl\I18n\t; + +class ServiceSlaReport extends SlaReport +{ + public function getName() + { + $name = t('Service SLA'); + if (Icinga::app()->getModuleManager()->hasEnabled('idoreports')) { + $name .= ' (Icinga DB)'; + } + + return $name; + } + + protected function createReportData() + { + return (new ReportData()) + ->setDimensions([t('Hostname'), t('Service Name')]) + ->setValues([t('SLA in %')]); + } + + protected function createReportRow($row) + { + if ($row->sla === null) { + return null; + } + + return (new ReportRow()) + ->setDimensions([$row->host->display_name, $row->display_name]) + ->setValues([(float) $row->sla]); + } + + protected function fetchSla(Timerange $timerange, Rule $filter = null) + { + $sla = Service::on($this->getDb()) + ->columns([ + 'host.display_name', + 'display_name', + 'sla' => new Expression(sprintf( + "get_sla_ok_percent(%s, %s, '%s', '%s')", + 'service.host_id', + 'service.id', + $timerange->getStart()->format('Uv'), + $timerange->getEnd()->format('Uv') + )) + ]); + + $sla->resetOrderBy()->orderBy('host.display_name')->orderBy('display_name'); + + $this->applyRestrictions($sla); + + if ($filter !== null) { + $sla->filter($filter); + } + + return $sla; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/SlaReport.php b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php new file mode 100644 index 0000000..8dcc64e --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php @@ -0,0 +1,297 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; + +use DateInterval; +use DatePeriod; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Reporting\Hook\ReportHook; +use Icinga\Module\Reporting\ReportData; +use Icinga\Module\Reporting\ReportRow; +use Icinga\Module\Reporting\Timerange; +use ipl\Html\Form; +use ipl\Html\Html; +use ipl\Stdlib\Filter\Rule; +use ipl\Web\Filter\QueryString; +use ipl\Web\Widget\EmptyState; + +use function ipl\I18n\t; + +/** + * Base class for host and service SLA reports + */ +abstract class SlaReport extends ReportHook +{ + use Auth; + use Database; + + /** @var float If an SLA value is lower than the threshold, it is considered not ok */ + const DEFAULT_THRESHOLD = 99.5; + + /** @var int The amount of decimal places for the report result */ + const DEFAULT_REPORT_PRECISION = 2; + + /** + * Create and return a {@link ReportData} container + * + * @return ReportData Container initialized with the expected dimensions and value labels for the specific report + */ + abstract protected function createReportData(); + + /** + * Create and return a {@link ReportRow} + * + * @param mixed $row Data for the row + * + * @return ReportRow|null Row with the dimensions and values for the specific report set according to the data + * expected in {@link createRepportData()} or null for no data + */ + abstract protected function createReportRow($row); + + /** + * Fetch SLA according to specified time range and filter + * + * @param Timerange $timerange + * @param Rule|null $filter + * + * @return iterable + */ + abstract protected function fetchSla(Timerange $timerange, Rule $filter = null); + + protected function fetchReportData(Timerange $timerange, array $config = null) + { + $rd = $this->createReportData(); + $rows = []; + + $filter = trim((string) $config['filter']) ?: '*'; + $filter = $filter !== '*' ? QueryString::parse($filter) : null; + + $interval = null; + $boundary = null; + $format = null; + if (isset($config['breakdown']) && $config['breakdown'] !== 'none') { + switch ($config['breakdown']) { + case 'hour': + $interval = new DateInterval('PT1H'); + $format = 'H:i:s'; + $boundary = '+1 hour'; + + break; + case 'day': + $interval = new DateInterval('P1D'); + $format = 'Y-m-d'; + $boundary = 'tomorrow midnight'; + + break; + case 'week': + $interval = new DateInterval('P1W'); + $format = 'Y-\WW'; + $boundary = 'monday next week midnight'; + + break; + case 'month': + $interval = new DateInterval('P1M'); + $format = 'Y-m'; + $boundary = 'first day of next month midnight'; + + break; + } + + $dimensions = $rd->getDimensions(); + $dimensions[] = ucfirst($config['breakdown']); + $rd->setDimensions($dimensions); + + foreach ($this->yieldTimerange($timerange, $interval, $boundary) as list($start, $end)) { + foreach ($this->fetchSla(new Timerange($start, $end), $filter) as $row) { + $row = $this->createReportRow($row); + + if ($row === null) { + continue; + } + + $dimensions = $row->getDimensions(); + $dimensions[] = $start->format($format); + $row->setDimensions($dimensions); + + $rows[] = $row; + } + } + } else { + foreach ($this->fetchSla($timerange, $filter) as $row) { + $rows[] = $this->createReportRow($row); + } + } + + $rd->setRows($rows); + + return $rd; + } + + /** + * Yield start and end times that recur at the specified interval over the given time range + * + * @param Timerange $timerange + * @param DateInterval $interval + * @param string|null $boundary English text datetime description for calculating bounds to get + * calendar days, weeks or months instead of relative times according to interval + * + * @return \Generator + */ + protected function yieldTimerange(Timerange $timerange, DateInterval $interval, $boundary = null) + { + $start = clone $timerange->getStart(); + $end = clone $timerange->getEnd(); + $oneSecond = new DateInterval('PT1S'); + + if ($boundary !== null) { + $intermediate = (clone $start)->modify($boundary); + if ($intermediate < $end) { + yield [clone $start, $intermediate->sub($oneSecond)]; + + $start->modify($boundary); + } + } + + $period = new DatePeriod($start, $interval, $end, DatePeriod::EXCLUDE_START_DATE); + + foreach ($period as $date) { + /** @var \DateTime $date */ + yield [$start, (clone $date)->sub($oneSecond)]; + + $start = $date; + } + + yield [$start, $end]; + } + + public function initConfigForm(Form $form) + { + $form->addElement('text', 'filter', [ + 'label' => t('Filter') + ]); + + $form->addElement('select', 'breakdown', [ + 'label' => t('Breakdown'), + 'options' => [ + 'none' => t('None', 'SLA Report Breakdown'), + 'hour' => t('Hour'), + 'day' => t('Day'), + 'week' => t('Week'), + 'month' => t('Month') + ] + ]); + + $form->addElement('number', 'threshold', [ + 'label' => t('Threshold'), + 'placeholder' => static::DEFAULT_THRESHOLD, + 'step' => '0.01', + 'min' => '1', + 'max' => '100' + ]); + + $form->addElement('number', 'sla_precision', [ + 'label' => t('Amount Decimal Places'), + 'placeholder' => static::DEFAULT_REPORT_PRECISION, + 'min' => '1', + 'max' => '12' + ]); + + $form->addElement('checkbox', 'export_total', [ + 'label' => t('Export Total Averages'), + 'description' => t('Export total averages to CSV and JSON'), + // Instead of y/n, 0/1 can be implicitly cast to bool which is done where the config is actually used. + 'checkedValue' => '1', + 'uncheckedValue' => '0' + ]); + } + + public function getData(Timerange $timerange, array $config = null) + { + return $this->fetchReportData($timerange, $config); + } + + public function getHtml(Timerange $timerange, array $config = null) + { + $data = $this->getData($timerange, $config); + + if (! count($data)) { + return new EmptyState(t('No data found.')); + } + + $threshold = isset($config['threshold']) ? (float) $config['threshold'] : static::DEFAULT_THRESHOLD; + + $tableHeaderCells = []; + + foreach ($data->getDimensions() as $dimension) { + $tableHeaderCells[] = Html::tag('th', null, $dimension); + } + + foreach ($data->getValues() as $value) { + $tableHeaderCells[] = Html::tag('th', null, $value); + } + + $tableRows = []; + $precision = $config['sla_precision'] ?? static::DEFAULT_REPORT_PRECISION; + + foreach ($data->getRows() as $row) { + $cells = []; + + foreach ($row->getDimensions() as $dimension) { + $cells[] = Html::tag('td', null, $dimension); + } + + // We only have one metric + $sla = $row->getValues()[0]; + + if ($sla < $threshold) { + $slaClass = 'nok'; + } else { + $slaClass = 'ok'; + } + + $cells[] = Html::tag('td', ['class' => "sla-column $slaClass"], round($sla, $precision)); + + $tableRows[] = Html::tag('tr', null, $cells); + } + + // We only have one average + $average = $data->getAverages()[0]; + + if ($average < $threshold) { + $slaClass = 'nok'; + } else { + $slaClass = 'ok'; + } + + $total = $this instanceof HostSlaReport + ? sprintf(t('Total (%d Hosts)'), $data->count()) + : sprintf(t('Total (%d Services)'), $data->count()); + + $tableRows[] = Html::tag('tr', null, [ + Html::tag('td', ['colspan' => count($data->getDimensions())], $total), + Html::tag('td', ['class' => "sla-column $slaClass"], round($average, $precision)) + ]); + + $table = Html::tag( + 'table', + ['class' => 'common-table sla-table'], + [ + Html::tag( + 'thead', + null, + Html::tag( + 'tr', + null, + $tableHeaderCells + ) + ), + Html::tag('tbody', null, $tableRows) + ] + ); + + return $table; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php new file mode 100644 index 0000000..b09ffb7 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php @@ -0,0 +1,19 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; + +use Icinga\Module\Icingadb\Hook\Common\TotalSlaReportUtils; + +use function ipl\I18n\t; + +class TotalHostSlaReport extends HostSlaReport +{ + use TotalSlaReportUtils; + + public function getName() + { + return t('Total Host SLA'); + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php new file mode 100644 index 0000000..e5ebf57 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php @@ -0,0 +1,19 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; + +use Icinga\Module\Icingadb\Hook\Common\TotalSlaReportUtils; + +use function ipl\I18n\t; + +class TotalServiceSlaReport extends ServiceSlaReport +{ + use TotalSlaReportUtils; + + public function getName() + { + return t('Total Service SLA'); + } +} diff --git a/library/Icingadb/ProvidedHook/X509/Sni.php b/library/Icingadb/ProvidedHook/X509/Sni.php new file mode 100644 index 0000000..6f20a7d --- /dev/null +++ b/library/Icingadb/ProvidedHook/X509/Sni.php @@ -0,0 +1,55 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\ProvidedHook\X509; + +use Generator; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\X509\Hook\SniHook; +use ipl\Web\Filter\QueryString; + +class Sni extends SniHook +{ + use Auth; + use Database; + + /** + * @inheritDoc + */ + public function getHosts(Filter $filter = null): Generator + { + $this->getDb()->ping(); + + $queryHost = Host::on($this->getDb()) + ->columns([ + 'host_name' => 'name', + 'host_address' => 'address', + 'host_address6' => 'address6' + ]); + + $this->applyRestrictions($queryHost); + + if ($filter !== null) { + $queryString = $filter->toQueryString(); + $filterCondition = QueryString::parse($queryString); + $queryHost->filter($filterCondition); + } + + $hosts = $this->getDb()->select($queryHost->assembleSelect()); + + /** @var Host $host */ + foreach ($hosts as $host) { + if (! empty($host->host_address)) { + yield $host->host_address => $host->host_name; + } + + if (! empty($host->host_address6)) { + yield $host->host_address6 => $host->host_name; + } + } + } +} diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php new file mode 100644 index 0000000..9418398 --- /dev/null +++ b/library/Icingadb/Redis/VolatileStateResults.php @@ -0,0 +1,170 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Redis; + +use Icinga\Application\Benchmark; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\IcingaRedis; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Orm\Query; +use ipl\Orm\Resolver; +use ipl\Orm\ResultSet; +use RuntimeException; + +class VolatileStateResults extends ResultSet +{ + use Auth; + + /** @var Resolver */ + private $resolver; + + /** @var bool Whether Redis is unavailable */ + private $redisUnavailable; + + /** @var bool Whether Redis updates were applied */ + private $updatesApplied = false; + + public static function fromQuery(Query $query) + { + $self = parent::fromQuery($query); + $self->resolver = $query->getResolver(); + $self->redisUnavailable = IcingaRedis::isUnavailable(); + + return $self; + } + + /** + * Get whether Redis is unavailable + * + * @return bool + */ + public function isRedisUnavailable(): bool + { + return $this->redisUnavailable; + } + + #[\ReturnTypeWillChange] + public function current() + { + if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) { + $this->rewind(); + } + + return parent::current(); + } + + public function next(): void + { + parent::next(); + + if (! $this->redisUnavailable && $this->isCacheDisabled && $this->valid()) { + $this->applyRedisUpdates([parent::current()]); + } + } + + public function key(): int + { + if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) { + $this->rewind(); + } + + return parent::key(); + } + + public function rewind(): void + { + if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) { + $this->updatesApplied = true; + $this->advance(); + + Benchmark::measure('Applying Redis updates'); + $this->applyRedisUpdates($this); + Benchmark::measure('Redis updates applied'); + } + + parent::rewind(); + } + + /** + * Apply redis state details to the given results + * + * @param self|array<int, mixed> $rows + * + * @return void + */ + protected function applyRedisUpdates($rows) + { + $type = null; + $behaviors = null; + + $keys = []; + $hostStateKeys = []; + + $showSourceGranted = $this->getAuth()->hasPermission('icingadb/object/show-source'); + + $states = []; + $hostStates = []; + foreach ($rows as $row) { + if ($type === null) { + $behaviors = $this->resolver->getBehaviors($row->state); + + switch (true) { + case $row instanceof Host: + $type = 'host'; + break; + case $row instanceof Service: + $type = 'service'; + break; + default: + throw new RuntimeException('Volatile states can only be fetched for hosts and services'); + } + } + + $states[bin2hex($row->id)] = $row->state; + if (empty($keys)) { + $keys = $row->state->getColumns(); + if (! $showSourceGranted) { + $keys = array_diff($keys, ['check_commandline']); + } + } + + if ($type === 'service' && $row->host instanceof Host) { + $hostStates[bin2hex($row->host->id)] = $row->host->state; + if (empty($hostStateKeys)) { + $hostStateKeys = $row->host->state->getColumns(); + } + } + } + + if (empty($states)) { + return; + } + + if ($type === 'service') { + $results = IcingaRedis::fetchServiceState(array_keys($states), $keys); + } else { + $results = IcingaRedis::fetchHostState(array_keys($states), $keys); + } + + foreach ($results as $id => $data) { + foreach ($data as $key => $value) { + $data[$key] = $behaviors->retrieveProperty($value, $key); + } + + $states[$id]->setProperties($data); + } + + if ($type === 'service' && ! empty($hostStates)) { + foreach (IcingaRedis::fetchHostState(array_keys($hostStates), $hostStateKeys) as $id => $data) { + foreach ($data as $key => $value) { + $data[$key] = $behaviors->retrieveProperty($value, $key); + } + + $hostStates[$id]->setProperties($data); + } + } + } +} diff --git a/library/Icingadb/Setup/ApiTransportPage.php b/library/Icingadb/Setup/ApiTransportPage.php new file mode 100644 index 0000000..e727e99 --- /dev/null +++ b/library/Icingadb/Setup/ApiTransportPage.php @@ -0,0 +1,128 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Icinga\Data\ConfigObject; +use Icinga\Module\Icingadb\Command\Transport\CommandTransport; +use Icinga\Module\Icingadb\Command\Transport\CommandTransportException; +use Icinga\Web\Form; + +class ApiTransportPage extends Form +{ + public function init() + { + $this->setName('setup_icingadb_api_transport'); + $this->setTitle(t('Icinga 2 API')); + $this->addDescription(t( + 'Please fill out the connection details to the Icinga 2 API.' + )); + $this->setValidatePartial(true); + } + + public function createElements(array $formData) + { + if (isset($formData['skip_validation']) && $formData['skip_validation']) { + // In case another error occured and the checkbox was displayed before + $this->addSkipValidationCheckbox(); + } else { + $this->addElement('hidden', 'skip_validation', ['value' => 0]); + } + + $this->addElement('hidden', 'transport', [ + 'required' => true, + 'disabled' => true, + 'value' => 'api' + ]); + $this->addElement('hidden', 'name', [ + 'required' => true, + 'disabled' => true, + 'value' => 'icinga2' + ]); + $this->addElement('text', 'host', [ + 'required' => true, + 'label' => t('Host'), + 'description' => t('Hostname or address of the Icinga master') + ]); + $this->addElement('number', 'port', [ + 'required' => true, + 'label' => t('Port'), + 'value' => 5665, + 'min' => 1, + 'max' => 65536 + ]); + $this->addElement('text', 'username', [ + 'required' => true, + 'label' => t('API Username'), + 'description' => t('User to authenticate with using HTTP Basic Auth') + ]); + $this->addElement('password', 'password', [ + 'required' => true, + 'renderPassword' => true, + 'label' => t('API Password'), + 'autocomplete' => 'new-password' + ]); + } + + public function isValid($formData) + { + if (! parent::isValid($formData)) { + return false; + } + + if (! isset($formData['skip_validation']) || !$formData['skip_validation']) { + if (! $this->validateConfiguration()) { + $this->addSkipValidationCheckbox(); + return false; + } + } + + return true; + } + + public function isValidPartial(array $formData) + { + if (isset($formData['backend_validation']) && parent::isValid($formData)) { + if (! $this->validateConfiguration()) { + return false; + } + + $this->info(t('The configuration has been successfully validated.')); + } elseif (! isset($formData['backend_validation'])) { + // This is usually done by isValid(Partial), but as we're not calling any of these... + $this->populate($formData); + } + + return true; + } + + protected function validateConfiguration(): bool + { + try { + CommandTransport::createTransport(new ConfigObject($this->getValues()))->probe(); + } catch (CommandTransportException $e) { + $this->error(sprintf( + t('Failed to successfully validate the configuration: %s'), + $e->getMessage() + )); + + return false; + } + + return true; + } + + protected function addSkipValidationCheckbox() + { + $this->addElement( + 'checkbox', + 'skip_validation', + [ + 'ignore' => true, + 'label' => t('Skip Validation'), + 'description' => t('Check this to not to validate the configuration') + ] + ); + } +} diff --git a/library/Icingadb/Setup/ApiTransportStep.php b/library/Icingadb/Setup/ApiTransportStep.php new file mode 100644 index 0000000..1e2e905 --- /dev/null +++ b/library/Icingadb/Setup/ApiTransportStep.php @@ -0,0 +1,102 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Exception; +use Icinga\Application\Config; +use Icinga\Exception\IcingaException; +use Icinga\Module\Setup\Step; +use ipl\Html\Attributes; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Html\Text; + +class ApiTransportStep extends Step +{ + /** @var array */ + protected $data; + + /** @var Exception */ + protected $error; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function apply() + { + $transportConfig = $this->data; + $transportName = $transportConfig['name']; + unset($transportConfig['name']); + + try { + $config = Config::module('icingadb', 'commandtransports', true); + $config->setSection($transportName, $transportConfig); + $config->saveIni(); + } catch (Exception $e) { + $this->error = $e; + return false; + } + + return true; + } + + public function getSummary() + { + $description = new HtmlElement('p', null, Text::create(mt( + 'icingadb', + 'The Icinga 2 API will be accessed using the following connection details:' + ))); + + $apiOptions = new Table(); + $apiOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Host'))), + $this->data['host'] + ])); + $apiOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Port'))), + $this->data['port'] + ])); + $apiOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Username'))), + $this->data['username'] + ])); + $apiOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Password'))), + str_repeat('*', strlen($this->data['password'])) + ])); + + $topic = new HtmlElement('div', Attributes::create(['class' => 'topic'])); + $topic->addHtml($description, $apiOptions); + + $summary = new HtmlDocument(); + $summary->addHtml( + new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga 2 API'))), + $topic + ); + + return $summary->render(); + } + + public function getReport() + { + if ($this->error === null) { + return [sprintf( + mt('icingadb', 'Commandtransport configuration update successful: %s'), + Config::module('icingadb', 'commandtransports')->getConfigFile() + )]; + } else { + return [ + sprintf( + mt('icingadb', 'Commandtransport configuration update failed: %s'), + Config::module('icingadb', 'commandtransports')->getConfigFile() + ), + sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error)) + ]; + } + } +} diff --git a/library/Icingadb/Setup/DbResourcePage.php b/library/Icingadb/Setup/DbResourcePage.php new file mode 100644 index 0000000..cc99dcc --- /dev/null +++ b/library/Icingadb/Setup/DbResourcePage.php @@ -0,0 +1,145 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Icinga\Forms\Config\ResourceConfigForm; +use Icinga\Forms\Config\Resource\DbResourceForm; +use Icinga\Web\Form; + +class DbResourcePage extends Form +{ + public function init() + { + $this->setName('setup_icingadb_resource'); + $this->setTitle(t('Icinga DB Resource')); + $this->addDescription(t( + 'Please fill out the connection details below to access Icinga DB.' + )); + $this->setValidatePartial(true); + } + + public function createElements(array $formData) + { + $this->addElement( + 'hidden', + 'type', + [ + 'required' => true, + 'disabled' => true, + 'value' => 'db' + ] + ); + + if (isset($formData['skip_validation']) && $formData['skip_validation']) { + // In case another error occured and the checkbox was displayed before + $this->addSkipValidationCheckbox(); + } else { + $this->addElement('hidden', 'skip_validation', ['value' => 0]); + } + + $dbResourceForm = new DbResourceForm(); + $this->addElements($dbResourceForm->createElements($formData)->getElements()); + $this->getElement('name')->setValue('icingadb'); + $this->getElement('db')->setMultiOptions([ + 'mysql' => 'MySQL', + 'pgsql' => 'PostgreSQL' + ]); + + $this->removeElement('name'); + $this->addElement( + 'hidden', + 'name', + [ + 'required' => true, + 'disabled' => true, + 'value' => 'icingadb' + ] + ); + + if (! isset($formData['db']) || $formData['db'] === 'mysql') { + $this->getElement('charset')->setValue('utf8mb4'); + } + } + + public function isValid($formData) + { + if (! parent::isValid($formData)) { + return false; + } + + if (! isset($formData['skip_validation']) || !$formData['skip_validation']) { + if (! $this->validateConfiguration()) { + $this->addSkipValidationCheckbox(); + return false; + } + } + + return true; + } + + public function isValidPartial(array $formData) + { + if (isset($formData['backend_validation']) && parent::isValid($formData)) { + if (! $this->validateConfiguration(true)) { + return false; + } + + $this->info(t('The configuration has been successfully validated.')); + } elseif (! isset($formData['backend_validation'])) { + // This is usually done by isValid(Partial), but as we're not calling any of these... + $this->populate($formData); + } + + return true; + } + + protected function validateConfiguration(bool $showLog = false): bool + { + $inspection = ResourceConfigForm::inspectResource($this); + if ($inspection !== null) { + if ($showLog) { + $join = function ($e) use (&$join) { + return is_string($e) ? $e : join("\n", array_map($join, $e)); + }; + $this->addElement( + 'note', + 'inspection_output', + [ + 'order' => 0, + 'value' => '<strong>' . t('Validation Log') . "</strong>\n\n" + . join("\n", array_map($join, $inspection->toArray())), + 'decorators' => [ + 'ViewHelper', + ['HtmlTag', ['tag' => 'pre', 'class' => 'log-output']], + ] + ] + ); + } + + if ($inspection->hasError()) { + $this->error(sprintf( + t('Failed to successfully validate the configuration: %s'), + $inspection->getError() + )); + return false; + } + } + + return true; + } + + protected function addSkipValidationCheckbox() + { + $this->addElement( + 'checkbox', + 'skip_validation', + [ + 'ignore' => true, + 'label' => t('Skip Validation'), + 'description' => t('Check this to not to validate the configuration') + ] + ); + } +} diff --git a/library/Icingadb/Setup/DbResourceStep.php b/library/Icingadb/Setup/DbResourceStep.php new file mode 100644 index 0000000..970d367 --- /dev/null +++ b/library/Icingadb/Setup/DbResourceStep.php @@ -0,0 +1,148 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Exception; +use Icinga\Application\Config; +use Icinga\Exception\IcingaException; +use Icinga\Module\Setup\Step; +use ipl\Html\Attributes; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Html\Text; + +class DbResourceStep extends Step +{ + /** @var array */ + protected $data; + + /** @var Exception */ + protected $error; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function apply() + { + $resourceConfig = $this->data; + $resourceName = $resourceConfig['name']; + unset($resourceConfig['name']); + + try { + $config = Config::app('resources', true); + $config->setSection($resourceName, $resourceConfig); + $config->saveIni(); + } catch (Exception $e) { + $this->error = $e; + return false; + } + + try { + $config = Config::module('icingadb', 'config', true); + $config->setSection('icingadb', ['resource' => $resourceName]); + $config->saveIni(); + } catch (Exception $e) { + $this->error = $e; + return false; + } + + return true; + } + + public function getSummary() + { + $description = new HtmlElement('p', null, Text::create(mt( + 'icingadb', + 'Icinga DB will be accessed using the following connection details:' + ))); + + $resourceOptions = new Table(); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Host'))), + $this->data['host'] + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Port'))), + $this->data['port'] ?: ($this->data['db'] === 'mysql' ? 3306 : 5432) + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Database'))), + $this->data['dbname'] + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Username'))), + $this->data['username'] + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Password'))), + str_repeat('*', strlen($this->data['password'])) + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Charset'))), + $this->data['charset'] + ])); + + if (isset($this->data['use_ssl']) && $this->data['use_ssl']) { + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('SSL Do Not Verify Server Certificate'))), + isset($this->data['ssl_do_not_verify_server_cert']) && $this->data['ssl_do_not_verify_server_cert'] + ? t('Yes') + : t('No') + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('SSL Key'))), + $this->data['ssl_key'] ?: mt('icingadb', 'None', 'non-existence of a value') + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('SSL Certificate'))), + $this->data['ssl_cert'] ?: mt('icingadb', 'None', 'non-existence of a value') + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('SSL CA'))), + $this->data['ssl_ca'] ?: mt('icingadb', 'None', 'non-existence of a value') + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('The CA certificate file path'))), + $this->data['ssl_capath'] ?: mt('icingadb', 'None', 'non-existence of a value') + ])); + $resourceOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('SSL CA Path'))), + $this->data['ssl_cipher'] ?: mt('icingadb', 'None', 'non-existence of a value') + ])); + } + + $topic = new HtmlElement('div', Attributes::create(['class' => 'topic'])); + $topic->addHtml($description, $resourceOptions); + + $summary = new HtmlDocument(); + $summary->addHtml( + new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga DB Resource'))), + $topic + ); + + return $summary->render(); + } + + public function getReport() + { + if ($this->error === null) { + return [sprintf( + mt('icingadb', 'Resource configuration update successful: %s'), + Config::resolvePath('resources.ini') + )]; + } else { + return [ + sprintf( + mt('icingadb', 'Resource configuration update failed: %s'), + Config::resolvePath('resources.ini') + ), + sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error)) + ]; + } + } +} diff --git a/library/Icingadb/Setup/IcingaDbWizard.php b/library/Icingadb/Setup/IcingaDbWizard.php new file mode 100644 index 0000000..f99f240 --- /dev/null +++ b/library/Icingadb/Setup/IcingaDbWizard.php @@ -0,0 +1,89 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Icinga\Application\Icinga; +use Icinga\Module\Setup\Forms\SummaryPage; +use Icinga\Module\Setup\Requirement\PhpModuleRequirement; +use Icinga\Module\Setup\Requirement\WebLibraryRequirement; +use Icinga\Module\Setup\RequirementSet; +use Icinga\Module\Setup\Setup; +use Icinga\Module\Setup\SetupWizard; +use Icinga\Web\Form; +use Icinga\Web\Request; +use Icinga\Web\Wizard; + +class IcingaDbWizard extends Wizard implements SetupWizard +{ + protected function init() + { + $this->addPage(new WelcomePage()); + $this->addPage(new DbResourcePage()); + $this->addPage(new RedisPage()); + $this->addPage(new ApiTransportPage()); + $this->addPage(new SummaryPage(['name' => 'setup_icingadb_summary'])); + } + + public function setupPage(Form $page, Request $request) + { + if ($page->getName() === 'setup_icingadb_summary') { + $page->setSummary($this->getSetup()->getSummary()); + $page->setSubjectTitle('Icinga DB Web'); + } + } + + public function getSetup() + { + $pageData = $this->getPageData(); + $setup = new Setup(); + + $setup->addStep(new DbResourceStep($pageData['setup_icingadb_resource'])); + $setup->addStep(new RedisStep($pageData['setup_icingadb_redis'])); + $setup->addStep(new ApiTransportStep($pageData['setup_icingadb_api_transport'])); + + return $setup; + } + + public function getRequirements() + { + $set = new RequirementSet(); + + $requiredVersions = Icinga::app()->getModuleManager()->getModule('icingadb')->getRequiredLibraries(); + + $set->add(new WebLibraryRequirement([ + 'condition' => ['icinga-php-library', '', $requiredVersions['icinga-php-library']], + 'alias' => 'Icinga PHP library', + 'description' => t('The Icinga PHP library (IPL) is required for Icinga DB Web') + ])); + + $set->add(new WebLibraryRequirement([ + 'condition' => ['icinga-php-thirdparty', '', $requiredVersions['icinga-php-thirdparty']], + 'alias' => 'Icinga PHP Thirdparty', + 'description' => t('The Icinga PHP Thirdparty library is required for Icinga DB Web') + ])); + + $set->add(new PhpModuleRequirement([ + 'condition' => 'libxml', + 'alias' => 'libxml', + 'description' => t('For check plugins that output HTML the libxml extension is required') + ])); + + $set->add(new PhpModuleRequirement([ + 'condition' => 'dom', + 'alias' => 'dom', + 'description' => t('For check plugins that output HTML the dom extension is required') + ])); + + $set->add(new PhpModuleRequirement([ + 'condition' => 'curl', + 'alias' => 'cURL', + 'description' => t( + 'To send external commands over Icinga 2\'s API, the cURL module for PHP is required.' + ) + ])); + + return $set; + } +} diff --git a/library/Icingadb/Setup/RedisPage.php b/library/Icingadb/Setup/RedisPage.php new file mode 100644 index 0000000..3c0a741 --- /dev/null +++ b/library/Icingadb/Setup/RedisPage.php @@ -0,0 +1,68 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Icinga\Module\Icingadb\Forms\RedisConfigForm; +use Icinga\Web\Form; + +class RedisPage extends Form +{ + public function init() + { + $this->setName('setup_icingadb_redis'); + $this->setTitle(t('Icinga DB Redis')); + $this->addDescription(t( + 'Please fill out the connection details to access the Icinga DB Redis.' + )); + $this->setValidatePartial(true); + } + + public function createElements(array $formData) + { + $redisConfigForm = new RedisConfigForm(); + $redisConfigForm->createElements($formData); + if (isset($formData['redis_tls']) && $formData['redis_tls']) { + $redisConfigForm->getElement('redis_ca_pem')->setIgnore(false); + $redisConfigForm->getElement('redis_cert_pem')->setIgnore(false); + $redisConfigForm->getElement('redis_key_pem')->setIgnore(false); + } + + $this->addElements($redisConfigForm->getElements()); + $this->addDisplayGroups($redisConfigForm->getDisplayGroups()); + } + + public function isValid($formData) + { + if (! parent::isValid($formData)) { + return false; + } + + if (($el = $this->getElement('skip_validation')) === null || ! $el->isChecked()) { + if (! RedisConfigForm::checkRedis($this)) { + if ($el === null) { + RedisConfigForm::addSkipValidationCheckbox($this); + RedisConfigForm::addInsecureCheckboxIfTls($this); + } + + return false; + } + } + + return true; + } + + public function isValidPartial(array $formData) + { + if (! parent::isValidPartial($formData)) { + return false; + } + + if (isset($formData['backend_validation'])) { + return RedisConfigForm::checkRedis($this); + } + + return true; + } +} diff --git a/library/Icingadb/Setup/RedisStep.php b/library/Icingadb/Setup/RedisStep.php new file mode 100644 index 0000000..97e50e0 --- /dev/null +++ b/library/Icingadb/Setup/RedisStep.php @@ -0,0 +1,205 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotWritableError; +use Icinga\File\Storage\LocalFileStorage; +use Icinga\Module\Setup\Step; +use ipl\Html\Attributes; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Html\Text; + +class RedisStep extends Step +{ + /** @var array */ + protected $data; + + /** @var Exception */ + protected $error; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function apply() + { + $moduleConfig = [ + 'redis' => [ + 'tls' => 0 + ] + ]; + $redisConfig = [ + 'redis1' => [ + 'host' => $this->data['redis1_host'], + 'port' => $this->data['redis1_port'] ?: null, + 'password' => $this->data['redis1_password'] ?: null + ] + ]; + if (isset($this->data['redis2_host']) && $this->data['redis2_host']) { + $redisConfig['redis2'] = [ + 'host' => $this->data['redis2_host'], + 'port' => $this->data['redis2_port'] ?: null, + 'password' => $this->data['redis2_password'] ?: null + ]; + } + + if (isset($this->data['redis_tls']) && $this->data['redis_tls']) { + $moduleConfig['redis']['tls'] = 1; + if (isset($this->data['redis_insecure']) && $this->data['redis_insecure']) { + $moduleConfig['redis']['insecure'] = 1; + } + + $storage = new LocalFileStorage(Icinga::app()->getStorageDir( + join(DIRECTORY_SEPARATOR, ['modules', 'icingadb', 'redis']) + )); + foreach (['ca', 'cert', 'key'] as $name) { + $textareaName = 'redis_' . $name . '_pem'; + if (isset($this->data[$textareaName]) && $this->data[$textareaName]) { + $pem = $this->data[$textareaName]; + $pemFile = md5($pem) . '-' . $name . '.pem'; + if (! $storage->has($pemFile)) { + try { + $storage->create($pemFile, $pem); + } catch (NotWritableError $e) { + $this->error = $e; + return false; + } + } + + $moduleConfig['redis'][$name] = $storage->resolvePath($pemFile); + } + } + } + + try { + $config = Config::module('icingadb', 'config', true); + foreach ($moduleConfig as $section => $options) { + $config->setSection($section, $options); + } + + $config->saveIni(); + + $config = Config::module('icingadb', 'redis', true); + foreach ($redisConfig as $section => $options) { + $config->setSection($section, $options); + } + + $config->saveIni(); + } catch (Exception $e) { + $this->error = $e; + return false; + } + + return true; + } + + public function getSummary() + { + $topic = new HtmlElement('div', Attributes::create(['class' => 'topic'])); + $topic->addHtml(new HtmlElement('p', null, Text::create(mt( + 'icingadb', + 'The Icinga DB Redis will be accessed using the following connection details:' + )))); + + $primaryOptions = new Table(); + $primaryOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Host'))), + $this->data['redis1_host'] + ])); + $primaryOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Port'))), + $this->data['redis1_port'] ?: 6380 + ])); + $primaryOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Password'))), + $this->data['redis1_password'] ?: mt('icingadb', 'None', 'non-existence of a value') + ])); + + if (isset($this->data['redis2_host']) && $this->data['redis2_host']) { + $topic->addHtml( + new HtmlElement('h3', null, Text::create(mt('icingadb', 'Primary'))), + $primaryOptions + ); + + $secondaryOptions = new Table(); + $secondaryOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Host'))), + $this->data['redis2_host'] + ])); + $secondaryOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Port'))), + $this->data['redis2_port'] ?: 6380 + ])); + $secondaryOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create(t('Password'))), + $this->data['redis2_password'] ?: mt('icingadb', 'None', 'non-existence of a value') + ])); + + $topic->addHtml( + new HtmlElement('h3', null, Text::create(mt('icingadb', 'Secondary'))), + $secondaryOptions + ); + } else { + $topic->addHtml($primaryOptions); + } + + $tlsOptions = new Table(); + $topic->addHtml($tlsOptions); + if (isset($this->data['redis_tls']) && $this->data['redis_tls']) { + if (isset($this->data['redis_cert_pem']) && $this->data['redis_cert_pem']) { + $tlsOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create('TLS')), + Text::create( + t('Icinga DB Web will authenticate against Redis with a client' + . ' certificate and private key over a secured connection') + ) + ])); + } else { + $tlsOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create('TLS')), + Text::create(t('Icinga DB Web will use secured Redis connections')) + ])); + } + } else { + $tlsOptions->addHtml(Table::row([ + new HtmlElement('strong', null, Text::create('TLS')), + Text::create(t('No')) + ])); + } + + $summary = new HtmlDocument(); + $summary->addHtml( + new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga DB Redis'))), + $topic + ); + + return $summary->render(); + } + + public function getReport() + { + if ($this->error === null) { + return [sprintf( + mt('icingadb', 'Module configuration update successful: %s'), + Config::module('icingab')->getConfigFile() + )]; + } else { + return [ + sprintf( + mt('icingadb', 'Module configuration update failed: %s'), + Config::module('icingab')->getConfigFile() + ), + sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error)) + ]; + } + } +} diff --git a/library/Icingadb/Setup/WelcomePage.php b/library/Icingadb/Setup/WelcomePage.php new file mode 100644 index 0000000..9f97c7d --- /dev/null +++ b/library/Icingadb/Setup/WelcomePage.php @@ -0,0 +1,56 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Setup; + +use Icinga\Web\Form; + +class WelcomePage extends Form +{ + public function init() + { + $this->setName('setup_icingadb_welcome'); + } + + public function createElements(array $formData) + { + $this->addElement( + 'note', + 'welcome', + array( + 'value' => t( + 'Welcome to the configuration of Icinga DB Web!' + ), + 'decorators' => array( + 'ViewHelper', + array('HtmlTag', array('tag' => 'h2')) + ) + ) + ); + + $this->addElement( + 'note', + 'description_1', + array( + 'value' => '<p>' . t( + 'Icinga DB Web is the UI for Icinga DB and provides' + . ' a graphical interface to your monitoring environment.' + ) . '</p>', + 'decorators' => array('ViewHelper') + ) + ); + + $this->addElement( + 'note', + 'description_2', + array( + 'value' => '<p>' . t( + 'The wizard will guide you through the configuration to' + . ' establish a connection with Icinga DB and Icinga 2.' + ) . '</p>', + 'decorators' => array('ViewHelper') + ) + ); + } +} diff --git a/library/Icingadb/Util/FeatureStatus.php b/library/Icingadb/Util/FeatureStatus.php new file mode 100644 index 0000000..94bf6d4 --- /dev/null +++ b/library/Icingadb/Util/FeatureStatus.php @@ -0,0 +1,50 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use ArrayObject; +use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand; + +class FeatureStatus extends ArrayObject +{ + public function __construct(string $type, $summary) + { + $prefix = "{$type}s"; + + $featureStatus = [ + ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS => + $this->getFeatureStatus('active_checks_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS => + $this->getFeatureStatus('passive_checks_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS => + $this->getFeatureStatus('notifications_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER => + $this->getFeatureStatus('event_handler_enabled', $prefix, $summary), + ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION => + $this->getFeatureStatus('flapping_enabled', $prefix, $summary) + ]; + + parent::__construct($featureStatus, ArrayObject::ARRAY_AS_PROPS); + } + + protected function getFeatureStatus(string $feature, string $prefix, $summary): int + { + $key = "{$prefix}_{$feature}"; + $value = (int) $summary->$key; + + if ($value === 0) { + return 0; + } + + $totalKey = "{$prefix}_total"; + $total = (int) $summary->$totalKey; + + if ($value === $total) { + return 1; + } + + return 2; + } +} diff --git a/library/Icingadb/Util/ObjectSuggestionsCursor.php b/library/Icingadb/Util/ObjectSuggestionsCursor.php new file mode 100644 index 0000000..0013b35 --- /dev/null +++ b/library/Icingadb/Util/ObjectSuggestionsCursor.php @@ -0,0 +1,25 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use ipl\Sql\Cursor; +use Iterator; + +class ObjectSuggestionsCursor extends Cursor +{ + public function getIterator(): \Traversable + { + foreach (parent::getIterator() as $key => $value) { + // TODO(lippserd): This is a quick and dirty fix for PostgreSQL binary datatypes for which PDO returns + // PHP resources that would cause exceptions since resources are not a valid type for attribute values. + // We need to do it this way as the suggestion implementation bypasses ORM behaviors here and there. + if (is_resource($value)) { + $value = stream_get_contents($value); + } + + yield $key => $value; + } + } +} diff --git a/library/Icingadb/Util/PerfData.php b/library/Icingadb/Util/PerfData.php new file mode 100644 index 0000000..cc33d16 --- /dev/null +++ b/library/Icingadb/Util/PerfData.php @@ -0,0 +1,703 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Web\Widget\Chart\InlinePie; +use InvalidArgumentException; +use LogicException; + +class PerfData +{ + const PERFDATA_OK = 'ok'; + const PERFDATA_WARNING = 'warning'; + const PERFDATA_CRITICAL = 'critical'; + + /** + * The performance data value being parsed + * + * @var string + */ + protected $perfdataValue; + + /** + * Unit of measurement (UOM) + * + * @var string + */ + protected $unit; + + /** + * The label + * + * @var string + */ + protected $label; + + /** + * The value + * + * @var ?float + */ + protected $value; + + /** + * The minimum value + * + * @var ?float + */ + protected $minValue; + + /** + * The maximum value + * + * @var ?float + */ + protected $maxValue; + + /** + * The WARNING threshold + * + * @var ThresholdRange + */ + protected $warningThreshold; + + /** + * The CRITICAL threshold + * + * @var ThresholdRange + */ + protected $criticalThreshold; + + /** + * The raw value + * + * @var ?string + */ + protected $rawValue; + + /** + * The raw minimum value + * + * @var ?string + */ + protected $rawMinValue; + + /** + * The raw maximum value + * + * @var ?string + */ + protected $rawMaxValue; + + /** + * Create a new PerfData object based on the given performance data label and value + * + * @param string $label The perfdata label + * @param string $value The perfdata value + */ + public function __construct(string $label, string $value) + { + $this->perfdataValue = $value; + $this->label = $label; + $this->parse(); + + if ($this->unit === '%') { + if ($this->minValue === null) { + $this->minValue = 0.0; + } + if ($this->maxValue === null) { + $this->maxValue = 100.0; + } + } + + $warn = $this->warningThreshold->getMax(); + if ($warn !== null) { + $crit = $this->criticalThreshold->getMax(); + if ($crit !== null && $warn > $crit) { + $this->warningThreshold->setInverted(); + $this->criticalThreshold->setInverted(); + } + } + } + + /** + * Return a new PerfData object based on the given performance data key=value pair + * + * @param string $perfdata The key=value pair to parse + * + * @return PerfData + * + * @throws InvalidArgumentException In case the given performance data has no content or a invalid format + */ + public static function fromString(string $perfdata): self + { + if (empty($perfdata)) { + throw new InvalidArgumentException('PerfData::fromString expects a string with content'); + } elseif (strpos($perfdata, '=') === false) { + throw new InvalidArgumentException( + 'PerfData::fromString expects a key=value formatted string. Got "' . $perfdata . '" instead' + ); + } + + list($label, $value) = explode('=', $perfdata, 2); + return new static(trim($label), trim($value)); + } + + /** + * Return whether this performance data's value is a number + * + * @return bool True in case it's a number, otherwise False + */ + public function isNumber(): bool + { + return $this->unit === null; + } + + /** + * Return whether this performance data's value are seconds + * + * @return bool True in case it's seconds, otherwise False + */ + public function isSeconds(): bool + { + return $this->unit === 's'; + } + + /** + * Return whether this performance data's value is a temperature + * + * @return bool True in case it's temperature, otherwise False + */ + public function isTemperature(): bool + { + return in_array($this->unit, array('C', 'F', 'K')); + } + + /** + * Return whether this performance data's value is in percentage + * + * @return bool True in case it's in percentage, otherwise False + */ + public function isPercentage(): bool + { + return $this->unit === '%'; + } + + /** + * Get whether this perf data's value is in packets + * + * @return bool True in case it's in packets + */ + public function isPackets(): bool + { + return $this->unit === 'packets'; + } + + /** + * Get whether this perf data's value is in lumen + * + * @return bool + */ + public function isLumens(): bool + { + return $this->unit === 'lm'; + } + + /** + * Get whether this perf data's value is in decibel-milliwatts + * + * @return bool + */ + public function isDecibelMilliWatts(): bool + { + return $this->unit === 'dBm'; + } + + /** + * Get whether this data's value is in bits + * + * @return bool + */ + public function isBits(): bool + { + return $this->unit === 'b'; + } + + /** + * Return whether this performance data's value is in bytes + * + * @return bool True in case it's in bytes, otherwise False + */ + public function isBytes(): bool + { + return $this->unit === 'B'; + } + + /** + * Get whether this data's value is in watt hours + * + * @return bool + */ + public function isWattHours(): bool + { + return $this->unit === 'Wh'; + } + + /** + * Get whether this data's value is in watt + * + * @return bool + */ + public function isWatts(): bool + { + return $this->unit === 'W'; + } + + /** + * Get whether this data's value is in ampere + * + * @return bool + */ + public function isAmperes(): bool + { + return $this->unit === 'A'; + } + + /** + * Get whether this data's value is in ampere seconds + * + * @return bool + */ + public function isAmpSeconds(): bool + { + return $this->unit === 'As'; + } + + /** + * Get whether this data's value is in volts + * + * @return bool + */ + public function isVolts(): bool + { + return $this->unit === 'V'; + } + + /** + * Get whether this data's value is in ohm + * + * @return bool + */ + public function isOhms(): bool + { + return $this->unit === 'O'; + } + + /** + * Get whether this data's value is in grams + * + * @return bool + */ + public function isGrams(): bool + { + return $this->unit === 'g'; + } + + /** + * Get whether this data's value is in Litters + * + * @return bool + */ + public function isLiters(): bool + { + return $this->unit === 'l'; + } + + /** + * Return whether this performance data's value is a counter + * + * @return bool True in case it's a counter, otherwise False + */ + public function isCounter(): bool + { + return $this->unit === 'c'; + } + + /** + * Returns whether it is possible to display a visual representation + * + * @return bool True when the perfdata is visualizable + */ + public function isVisualizable(): bool + { + return isset($this->minValue, $this->maxValue, $this->value) && $this->isValid(); + } + + /** + * Return this perfomance data's label + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * Return the value or null if it is unknown (U) + * + * @return null|float + */ + public function getValue() + { + return $this->value; + } + + /** + * Return the unit as a string + * + * @return ?string + */ + public function getUnit() + { + return $this->unit; + } + + /** + * Return the value as percentage (0-100) + * + * @return null|float + */ + public function getPercentage() + { + if ($this->isPercentage()) { + return $this->value; + } + + if ($this->maxValue !== null) { + $minValue = $this->minValue !== null ? $this->minValue : 0.0; + if ($this->maxValue == $minValue) { + return null; + } + + if ($this->value > $minValue) { + return (($this->value - $minValue) / ($this->maxValue - $minValue)) * 100; + } + } + } + + /** + * Return this performance data's warning treshold + * + * @return ThresholdRange + */ + public function getWarningThreshold(): ThresholdRange + { + return $this->warningThreshold; + } + + /** + * Return this performance data's critical treshold + * + * @return ThresholdRange + */ + public function getCriticalThreshold(): ThresholdRange + { + return $this->criticalThreshold; + } + + /** + * Return the minimum value or null if it is not available + * + * @return ?float + */ + public function getMinimumValue() + { + return $this->minValue; + } + + /** + * Return the maximum value or null if it is not available + * + * @return null|float + */ + public function getMaximumValue() + { + return $this->maxValue; + } + + /** + * Return this performance data as string + * + * @return string + */ + public function __toString() + { + return $this->formatLabel(); + } + + /** + * Parse the current performance data value + * + * @todo Handle optional min/max if UOM == % + */ + protected function parse() + { + $parts = explode(';', $this->perfdataValue); + + $matches = array(); + if (preg_match('@^(U|-?(?:\d+)?(?:\.\d+)?)([a-zA-TV-Z%°]{1,3})$@u', $parts[0], $matches)) { + $this->unit = $matches[2]; + $value = $matches[1]; + } else { + $value = $parts[0]; + } + + if (! is_numeric($value)) { + if ($value !== 'U') { + $this->rawValue = $parts[0]; + } + + $this->value = null; + } else { + $this->value = floatval($value); + } + + switch (count($parts)) { + /* @noinspection PhpMissingBreakStatementInspection */ + case 5: + if ($parts[4] !== '') { + if (is_numeric($parts[4])) { + $this->maxValue = floatval($parts[4]); + } else { + $this->rawMaxValue = $parts[4]; + } + } + /* @noinspection PhpMissingBreakStatementInspection */ + case 4: + if ($parts[3] !== '') { + if (is_numeric($parts[3])) { + $this->minValue = floatval($parts[3]); + } else { + $this->rawMinValue = $parts[3]; + } + } + /* @noinspection PhpMissingBreakStatementInspection */ + case 3: + $this->criticalThreshold = ThresholdRange::fromString(trim($parts[2])); + // Fallthrough + case 2: + $this->warningThreshold = ThresholdRange::fromString(trim($parts[1])); + } + + if ($this->warningThreshold === null) { + $this->warningThreshold = new ThresholdRange(); + } + if ($this->criticalThreshold === null) { + $this->criticalThreshold = new ThresholdRange(); + } + } + + protected function calculatePieChartData(): array + { + $rawValue = $this->getValue(); + $minValue = $this->getMinimumValue() !== null ? $this->getMinimumValue() : 0; + $usedValue = ($rawValue - $minValue); + + $green = $orange = $red = 0; + + if ($this->criticalThreshold->contains($rawValue)) { + if ($this->warningThreshold->contains($rawValue)) { + $green = $usedValue; + } else { + $orange = $usedValue; + } + } else { + $red = $usedValue; + } + + return array($green, $orange, $red, ($this->getMaximumValue() - $minValue) - $usedValue); + } + + + public function asInlinePie(): InlinePie + { + if (! $this->isVisualizable()) { + throw new LogicException('Cannot calculate piechart data for unvisualizable perfdata entry.'); + } + + $data = $this->calculatePieChartData(); + $pieChart = new InlinePie($data, $this); + $pieChart->setColors(array('#44bb77', '#ffaa44', '#ff5566', '#ddccdd')); + + return $pieChart; + } + + /** + * Format the given value depending on the currently used unit + */ + protected function format($value) + { + if ($value === null) { + return null; + } + + if ($value instanceof ThresholdRange) { + if (! $value->isValid()) { + return (string) $value; + } + + if ($value->getMin()) { + return (string) $value; + } + + $max = $value->getMax(); + return $max === null ? '' : $this->format($max); + } + + switch (true) { + case $this->isPercentage(): + return (string) $value . '%'; + case $this->isPackets(): + return (string) $value . 'packets'; + case $this->isLumens(): + return (string) $value . 'lm'; + case $this->isDecibelMilliWatts(): + return (string) $value . 'dBm'; + case $this->isCounter(): + return (string) $value . 'c'; + case $this->isTemperature(): + return (string) $value . $this->unit; + case $this->isBits(): + return PerfDataFormat::bits($value); + case $this->isBytes(): + return PerfDataFormat::bytes($value); + case $this->isSeconds(): + return PerfDataFormat::seconds($value); + case $this->isWatts(): + return PerfDataFormat::watts($value); + case $this->isWattHours(): + return PerfDataFormat::wattHours($value); + case $this->isAmperes(): + return PerfDataFormat::amperes($value); + case $this->isAmpSeconds(): + return PerfDataFormat::ampereSeconds($value); + case $this->isVolts(): + return PerfDataFormat::volts($value); + case $this->isOhms(): + return PerfDataFormat::ohms($value); + case $this->isGrams(): + return PerfDataFormat::grams($value); + case $this->isLiters(): + return PerfDataFormat::liters($value); + case ! is_numeric($value): + return $value; + default: + return number_format($value, 2) . ($this->unit !== null ? ' ' . $this->unit : ''); + } + } + + /** + * Format the title string that represents this perfdata set + * + * @param bool $html + * + * @return string + */ + public function formatLabel(bool $html = false): string + { + return sprintf( + $html ? '<b>%s %s</b> (%s%%)' : '%s %s (%s%%)', + htmlspecialchars($this->getLabel()), + $this->format($this->value), + number_format($this->getPercentage() ?? 0, 2) + ); + } + + public function toArray(): array + { + return [ + 'label' => $this->getLabel(), + 'value' => isset($this->value) ? $this->format($this->value) : $this->rawValue, + 'min' => (string) ( + ! $this->isPercentage() + ? (isset($this->minValue) ? $this->format($this->minValue) : $this->rawMinValue) + : null + ), + 'max' => (string) ( + ! $this->isPercentage() + ? (isset($this->maxValue) ? $this->format($this->maxValue) : $this->rawMaxValue) + : null + ), + 'warn' => $this->format($this->warningThreshold), + 'crit' => $this->format($this->criticalThreshold) + ]; + } + + /** + * Return the state indicated by this perfdata + * + * @return int + */ + public function getState(): int + { + if (! is_numeric($this->value)) { + return ServiceStates::UNKNOWN; + } + + if (! $this->criticalThreshold->contains($this->value)) { + return ServiceStates::CRITICAL; + } + + if (! $this->warningThreshold->contains($this->value)) { + return ServiceStates::WARNING; + } + + return ServiceStates::OK; + } + + /** + * Return whether the state indicated by this perfdata is worse than + * the state indicated by the other perfdata + * CRITICAL > UNKNOWN > WARNING > OK + * + * @param PerfData $rhs the other perfdata + * + * @return bool + */ + public function worseThan(PerfData $rhs): bool + { + if (($state = $this->getState()) === ($rhsState = $rhs->getState())) { + return $this->getPercentage() > $rhs->getPercentage(); + } + + if ($state === ServiceStates::CRITICAL) { + return true; + } + + if ($state === ServiceStates::UNKNOWN) { + return $rhsState !== ServiceStates::CRITICAL; + } + + if ($state === ServiceStates::WARNING) { + return $rhsState === ServiceStates::OK; + } + + return false; + } + + /** + * Returns whether the performance data can be evaluated + * + * @return bool + */ + public function isValid(): bool + { + return ! isset($this->rawValue) + && ! isset($this->rawMinValue) + && ! isset($this->rawMaxValue) + && $this->criticalThreshold->isValid() + && $this->warningThreshold->isValid(); + } +} diff --git a/library/Icingadb/Util/PerfDataFormat.php b/library/Icingadb/Util/PerfDataFormat.php new file mode 100644 index 0000000..1caffff --- /dev/null +++ b/library/Icingadb/Util/PerfDataFormat.php @@ -0,0 +1,171 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +class PerfDataFormat +{ + protected static $instance; + + protected static $generalBase = 1000; + + protected static $bitPrefix = ['b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb']; + + protected static $bytePrefix = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + protected static $wattHourPrefix = ['Wh', 'kWh', 'MWh', 'GWh', 'TWh', 'PWh', 'EWh', 'ZWh', 'YWh']; + + protected static $wattPrefix = [-1 => 'mW', 'W', 'kW', 'MW', 'GW']; + + protected static $amperePrefix = [-3 => 'nA', -2 => 'µA', -1 => 'mA', 'A', 'kA', 'MA', 'GA']; + + protected static $ampSecondPrefix = [-2 => 'µAs', -1 => 'mAs', 'As', 'kAs', 'MAs', 'GAs']; + + protected static $voltPrefix = [-2 => 'µV', -1 => 'mV', 'V', 'kV', 'MV', 'GV']; + + protected static $ohmPrefix = ['Ω']; + + protected static $gramPrefix = [ + -5 => 'fg', + -4 => 'pg', + -3 => 'ng', + -2 => 'µg', + -1 => 'mg', + 'g', + 'kg', + 't', + 'ktÇ‚', + 'Mt', + 'Gt' + ]; + + protected static $literPrefix = [ + -5 => 'fl', + -4 => 'pl', + -3 => 'nl', + -2 => 'µl', + -1 => 'ml', + 'l', + 'kl', + 'Ml', + 'Gl', + 'Tl', + 'Pl' + ]; + + protected static $secondPrefix = [-3 => 'ns', -2 => 'µs', -1 => 'ms', 's']; + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new PerfDataFormat(); + } + + return self::$instance; + } + + public static function bits($value): string + { + return self::formatForUnits($value, self::$bitPrefix, self::$generalBase); + } + + public static function bytes($value): string + { + return self::formatForUnits($value, self::$bytePrefix, self::$generalBase); + } + + public static function wattHours($value): string + { + return self::formatForUnits($value, self::$wattHourPrefix, self::$generalBase); + } + + public static function watts($value): string + { + return self::formatForUnits($value, self::$wattPrefix, self::$generalBase); + } + + public static function amperes($value): string + { + return self::formatForUnits($value, self::$amperePrefix, self::$generalBase); + } + + public static function ampereSeconds($value): string + { + return self::formatForUnits($value, self::$ampSecondPrefix, self::$generalBase); + } + + public static function volts($value): string + { + return self::formatForUnits($value, self::$voltPrefix, self::$generalBase); + } + + public static function ohms($value): string + { + return self::formatForUnits($value, self::$ohmPrefix, self::$generalBase); + } + + public static function grams($value): string + { + return self::formatForUnits($value, self::$gramPrefix, self::$generalBase); + } + + public static function liters($value): string + { + return self::formatForUnits($value, self::$literPrefix, self::$generalBase); + } + + public static function seconds($value): string + { + $value = (float) $value; + $absValue = abs($value); + + if ($absValue < 60) { + return self::formatForUnits($value, self::$secondPrefix, self::$generalBase); + } elseif ($absValue < 3600) { + return sprintf('%0.2f m', $value / 60); + } elseif ($absValue < 86400) { + return sprintf('%0.2f h', $value / 3600); + } + + return sprintf('%0.2f d', $value / 86400); + } + + protected static function formatForUnits(float $value, array &$units, int $base): string + { + $sign = ''; + if ($value < 0) { + $value = abs($value); + $sign = '-'; + } + + if ($value == 0) { + $pow = $result = 0; + } else { + $pow = floor(log($value, $base)); + + // Identify nearest unit if unknown + while (! isset($units[$pow])) { + if ($pow < 0) { + $pow++; + } else { + $pow--; + } + } + + $result = $value / pow($base, $pow); + } + + // 1034.23 looks better than 1.03, but 2.03 is fine: + if ($pow > 0 && $result < 2) { + $result = $value / pow($base, --$pow); + } + + return sprintf( + '%s%0.2f %s', + $sign, + $result, + $units[$pow] + ); + } +} diff --git a/library/Icingadb/Util/PerfDataSet.php b/library/Icingadb/Util/PerfDataSet.php new file mode 100644 index 0000000..df31393 --- /dev/null +++ b/library/Icingadb/Util/PerfDataSet.php @@ -0,0 +1,172 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use ArrayIterator; +use IteratorAggregate; + +class PerfDataSet implements IteratorAggregate +{ + /** + * The performance data being parsed + * + * @var string + */ + protected $perfdataStr; + + /** + * The current parsing position + * + * @var int + */ + protected $parserPos = 0; + + /** + * A list of PerfData objects + * + * @var array + */ + protected $perfdata = array(); + + /** + * Create a new set of performance data + * + * @param string $perfdataStr A space separated list of label/value pairs + */ + protected function __construct(string $perfdataStr) + { + if (($perfdataStr = trim($perfdataStr)) !== '') { + $this->perfdataStr = $perfdataStr; + $this->parse(); + } + } + + /** + * Return a iterator for this set of performance data + * + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->asArray()); + } + + /** + * Return a new set of performance data + * + * @param string $perfdataStr A space separated list of label/value pairs + * + * @return PerfDataSet + */ + public static function fromString(string $perfdataStr): self + { + return new static($perfdataStr); + } + + /** + * Return this set of performance data as array + * + * @return array + */ + public function asArray(): array + { + return $this->perfdata; + } + + /** + * Parse the current performance data + */ + protected function parse() + { + while ($this->parserPos < strlen($this->perfdataStr)) { + $label = trim($this->readLabel()); + $value = trim($this->readUntil(' ')); + + if ($label) { + $this->perfdata[] = new PerfData($label, $value); + } + } + + uasort( + $this->perfdata, + function ($a, $b) { + if ($a->isVisualizable() && ! $b->isVisualizable()) { + return -1; + } elseif (! $a->isVisualizable() && $b->isVisualizable()) { + return 1; + } elseif (! $a->isVisualizable() && ! $b->isVisualizable()) { + return 0; + } + + return $a->worseThan($b) ? -1 : ($b->worseThan($a) ? 1 : 0); + } + ); + } + + /** + * Return the next label found in the performance data + * + * @return string The label found + */ + protected function readLabel(): string + { + $this->skipSpaces(); + if (in_array($this->perfdataStr[$this->parserPos], array('"', "'"))) { + $quoteChar = $this->perfdataStr[$this->parserPos++]; + $label = $this->readUntil($quoteChar, '='); + $this->parserPos++; + + if ($this->perfdataStr[$this->parserPos] === '=') { + $this->parserPos++; + } + } else { + $label = $this->readUntil('='); + $this->parserPos++; + } + + $this->skipSpaces(); + return $label; + } + + /** + * Return all characters between the current parser position and the given character + * + * @param string $stopChar The character on which to stop + * @param string $backtrackOn The character on which to backtrack + * + * @return string + */ + protected function readUntil(string $stopChar, string $backtrackOn = null): string + { + $start = $this->parserPos; + $breakCharEncounteredAt = null; + $stringExhaustedAt = strlen($this->perfdataStr); + while ($this->parserPos < $stringExhaustedAt) { + if ($this->perfdataStr[$this->parserPos] === $stopChar) { + break; + } elseif ($breakCharEncounteredAt === null && $this->perfdataStr[$this->parserPos] === $backtrackOn) { + $breakCharEncounteredAt = $this->parserPos; + } + + $this->parserPos++; + } + + if ($breakCharEncounteredAt !== null && $this->parserPos === $stringExhaustedAt) { + $this->parserPos = $breakCharEncounteredAt; + } + + return substr($this->perfdataStr, $start, $this->parserPos - $start); + } + + /** + * Advance the parser position to the next non-whitespace character + */ + protected function skipSpaces() + { + while ($this->parserPos < strlen($this->perfdataStr) && $this->perfdataStr[$this->parserPos] === ' ') { + $this->parserPos++; + } + } +} diff --git a/library/Icingadb/Util/PluginOutput.php b/library/Icingadb/Util/PluginOutput.php new file mode 100644 index 0000000..71d08b1 --- /dev/null +++ b/library/Icingadb/Util/PluginOutput.php @@ -0,0 +1,260 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +use DOMDocument; +use DOMNode; +use DOMText; +use Icinga\Module\Icingadb\Hook\PluginOutputHook; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Web\Dom\DomNodeIterator; +use Icinga\Web\Helper\HtmlPurifier; +use InvalidArgumentException; +use ipl\Html\HtmlString; +use ipl\Orm\Model; +use LogicException; +use RecursiveIteratorIterator; + +class PluginOutput extends HtmlString +{ + /** @var string[] Patterns to be replaced in plain text plugin output */ + const TEXT_PATTERNS = [ + '~\\\t~', + '~\\\n~', + '~(\[|\()OK(\]|\))~', + '~(\[|\()WARNING(\]|\))~', + '~(\[|\()CRITICAL(\]|\))~', + '~(\[|\()UNKNOWN(\]|\))~', + '~(\[|\()UP(\]|\))~', + '~(\[|\()DOWN(\]|\))~', + '~\@{6,}~' + ]; + + /** @var string[] Replacements for {@see PluginOutput::TEXT_PATTERNS} */ + const TEXT_REPLACEMENTS = [ + "\t", + "\n", + '<span class="state-ball ball-size-m state-ok"></span>', + '<span class="state-ball ball-size-m state-warning"></span>', + '<span class="state-ball ball-size-m state-critical"></span>', + '<span class="state-ball ball-size-m state-unknown"></span>', + '<span class="state-ball ball-size-m state-up"></span>', + '<span class="state-ball ball-size-m state-down"></span>', + '@@@@@@' + ]; + + /** @var string[] Patterns to be replaced in html plugin output */ + const HTML_PATTERNS = [ + '~\\\t~', + '~\\\n~' + ]; + + /** @var string[] Replacements for {@see PluginOutput::HTML_PATTERNS} */ + const HTML_REPLACEMENTS = [ + "\t", + "\n" + ]; + + /** @var string Already rendered output */ + protected $renderedOutput; + + /** @var bool Whether the output contains HTML */ + protected $isHtml; + + /** @var bool Whether output will be enriched */ + protected $enrichOutput = true; + + /** @var string The name of the command that produced the output */ + protected $commandName; + + /** + * Get whether the output contains HTML + * + * Requires the output being already rendered. + * + * @return bool + * + * @throws LogicException In case the output hasn't been rendered yet + */ + public function isHtml(): bool + { + if ($this->isHtml === null) { + if (empty($this->getContent())) { + // "Nothing" can't be HTML + return false; + } + + throw new LogicException('Output not rendered yet'); + } + + return $this->isHtml; + } + + /** + * Set whether the output should be enriched + * + * @param bool $state + * + * @return $this + */ + public function setEnrichOutput(bool $state = true): self + { + $this->enrichOutput = $state; + + return $this; + } + + /** + * Set name of the command that produced the output + * + * @param string $name + * + * @return $this + */ + public function setCommandName(string $name): self + { + $this->commandName = $name; + + return $this; + } + + /** + * Render plugin output of the given object + * + * @param Host|Service $object + * + * @return static + * + * @throws InvalidArgumentException If $object is neither a host nor a service + */ + public static function fromObject(Model $object): self + { + if (! $object instanceof Host && ! $object instanceof Service) { + throw new InvalidArgumentException( + sprintf('Object is not a host or service, got %s instead', get_class($object)) + ); + } + + return (new static($object->state->output . "\n" . $object->state->long_output)) + ->setCommandName($object->checkcommand_name); + } + + public function render() + { + if ($this->renderedOutput !== null) { + return $this->renderedOutput; + } + + $output = parent::render(); + if (empty($output)) { + return ''; + } + + if ($this->commandName !== null) { + $output = PluginOutputHook::processOutput($output, $this->commandName, $this->enrichOutput); + } + + if (preg_match('~<\w+(?>\s\w+=[^>]*)?>~', $output)) { + // HTML + $output = HtmlPurifier::process(preg_replace( + self::HTML_PATTERNS, + self::HTML_REPLACEMENTS, + $output + )); + $this->isHtml = true; + } else { + // Plaintext + $output = preg_replace( + self::TEXT_PATTERNS, + self::TEXT_REPLACEMENTS, + htmlspecialchars($output, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, null, false) + ); + $this->isHtml = false; + } + + $output = trim($output); + + // Add zero-width space after commas which are not followed by a whitespace character + // in oder to help browsers to break words in plugin output + $output = preg_replace('/,(?=[^\s])/', ',​', $output); + + if ($this->enrichOutput && $this->isHtml) { + $output = $this->processHtml($output); + } + + $this->renderedOutput = $output; + + return $output; + } + + /** + * Replace color state information, if any + * + * @param string $html + * + * @todo Do we really need to create a DOM here? Or is a preg_replace like we do it for text also feasible? + * @return string + */ + protected function processHtml(string $html): string + { + $pattern = '/[([](OK|WARNING|CRITICAL|UNKNOWN|UP|DOWN)[)\]]/'; + $doc = new DOMDocument(); + $doc->loadXML('<div>' . $html . '</div>', LIBXML_NOERROR | LIBXML_NOWARNING); + $dom = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST); + + $nodesToRemove = []; + foreach ($dom as $node) { + /** @var DOMNode $node */ + if ($node->nodeType !== XML_TEXT_NODE) { + continue; + } + + $start = 0; + while (preg_match($pattern, $node->nodeValue, $match, PREG_OFFSET_CAPTURE, $start)) { + $offsetLeft = $match[0][1]; + $matchLength = strlen($match[0][0]); + $leftLength = $offsetLeft - $start; + + // if there is text before the match + if ($leftLength) { + // create node for leading text + $text = new DOMText(substr($node->nodeValue, $start, $leftLength)); + $node->parentNode->insertBefore($text, $node); + } + + // create the state ball for the match + $span = $doc->createElement('span'); + $span->setAttribute( + 'class', + 'state-ball ball-size-m state-' . strtolower($match[1][0]) + ); + $node->parentNode->insertBefore($span, $node); + + // start for next match + $start = $offsetLeft + $matchLength; + } + + if ($start) { + // is there text left? + if (strlen($node->nodeValue) > $start) { + // create node for trailing text + $text = new DOMText(substr($node->nodeValue, $start)); + $node->parentNode->insertBefore($text, $node); + } + + // delete the old node later + $nodesToRemove[] = $node; + } + } + + foreach ($nodesToRemove as $node) { + /** @var DOMNode $node */ + $node->parentNode->removeChild($node); + } + + return substr($doc->saveHTML(), 5, -7); + } +} diff --git a/library/Icingadb/Util/ThresholdRange.php b/library/Icingadb/Util/ThresholdRange.php new file mode 100644 index 0000000..675697a --- /dev/null +++ b/library/Icingadb/Util/ThresholdRange.php @@ -0,0 +1,213 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Util; + +/** + * The warning/critical threshold of a measured value + */ +class ThresholdRange +{ + /** + * The smallest value inside the range (null stands for -∞) + * + * @var float|null + */ + protected $min; + + /** + * The biggest value inside the range (null stands for ∞) + * + * @var float|null + */ + protected $max; + + /** + * Whether to invert the result of contains() + * + * @var bool + */ + protected $inverted = false; + + /** + * The unmodified range as passed to fromString() + * + * @var string + */ + protected $raw; + + /** + * Whether the threshold range is valid + * + * @var bool + */ + protected $isValid = true; + + /** + * Create a new instance based on a threshold range conforming to <https://nagios-plugins.org/doc/guidelines.html> + * + * @param string $rawRange + * + * @return ThresholdRange + */ + public static function fromString(string $rawRange): self + { + $range = new static(); + $range->raw = $rawRange; + + if ($rawRange == '') { + return $range; + } + + $rawRange = ltrim($rawRange); + if (substr($rawRange, 0, 1) === '@') { + $range->setInverted(); + $rawRange = substr($rawRange, 1); + } + + if (strpos($rawRange, ':') === false) { + $min = 0.0; + $max = trim($rawRange); + if (! is_numeric($max)) { + $range->isValid = false; + return $range; + } + + $max = floatval(trim($rawRange)); + } else { + list($min, $max) = explode(':', $rawRange, 2); + $min = trim($min); + $max = trim($max); + + switch ($min) { + case '': + $min = 0.0; + break; + case '~': + $min = null; + break; + default: + if (! is_numeric($min)) { + $range->isValid = false; + return $range; + } + + $min = floatval($min); + } + + if (! empty($max) && ! is_numeric($max)) { + $range->isValid = false; + return $range; + } + + $max = empty($max) ? null : floatval($max); + } + + return $range->setMin($min) + ->setMax($max); + } + + /** + * Set the smallest value inside the range (null stands for -∞) + * + * @param float|null $min + * + * @return $this + */ + public function setMin(?float $min): self + { + $this->min = $min; + return $this; + } + + /** + * Get the smallest value inside the range (null stands for -∞) + * + * @return float|null + */ + public function getMin() + { + return $this->min; + } + + /** + * Set the biggest value inside the range (null stands for ∞) + * + * @param float|null $max + * + * @return $this + */ + public function setMax(?float $max): self + { + $this->max = $max; + return $this; + } + + /** + * Get the biggest value inside the range (null stands for ∞) + * + * @return float|null + */ + public function getMax() + { + return $this->max; + } + + /** + * Set whether to invert the result of contains() + * + * @param bool $inverted + * + * @return $this + */ + public function setInverted(bool $inverted = true): self + { + $this->inverted = $inverted; + return $this; + } + + /** + * Get whether to invert the result of contains() + * + * @return bool + */ + public function isInverted(): bool + { + return $this->inverted; + } + + /** + * Return whether $value is inside $this + * + * @param float $value + * + * @return bool + */ + public function contains(float $value): bool + { + return (bool) ($this->inverted ^ ( + ($this->min === null || $this->min <= $value) && ($this->max === null || $this->max >= $value) + )); + } + + /** + * Return whether the threshold range is valid + * + * @return bool + */ + public function isValid() + { + return $this->isValid; + } + + /** + * Return the textual representation of $this, suitable for fromString() + * + * @return string + */ + public function __toString() + { + return (string) $this->raw; + } +} diff --git a/library/Icingadb/Web/Control/GridViewModeSwitcher.php b/library/Icingadb/Web/Control/GridViewModeSwitcher.php new file mode 100644 index 0000000..df5524b --- /dev/null +++ b/library/Icingadb/Web/Control/GridViewModeSwitcher.php @@ -0,0 +1,38 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Control; + +/** + * View mode switcher to toggle between grid and list view + */ +class GridViewModeSwitcher extends ViewModeSwitcher +{ + /** @var string Default view mode */ + public const DEFAULT_VIEW_MODE = 'list'; + + /** @var array View mode-icon pairs */ + public static $viewModes = [ + 'list' => 'default', + 'grid' => 'grid' + ]; + + protected function getTitle(string $viewMode): string + { + $active = null; + $inactive = null; + switch ($viewMode) { + case 'list': + $active = t('List view active'); + $inactive = t('Switch to list view'); + break; + case 'grid': + $active = t('Grid view active'); + $inactive = t('Switch to grid view'); + break; + } + + return $viewMode === $this->getViewMode() ? $active : $inactive; + } +} diff --git a/library/Icingadb/Web/Control/ProblemToggle.php b/library/Icingadb/Web/Control/ProblemToggle.php new file mode 100644 index 0000000..c5aed82 --- /dev/null +++ b/library/Icingadb/Web/Control/ProblemToggle.php @@ -0,0 +1,74 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Control; + +use ipl\Web\Common\FormUid; +use ipl\Web\Compat\CompatForm; + +class ProblemToggle extends CompatForm +{ + use FormUid; + + protected $filter; + + protected $protector; + + protected $defaultAttributes = [ + 'name' => 'problem-toggle', + 'class' => 'icinga-form icinga-controls inline' + ]; + + public function __construct($filter) + { + $this->filter = $filter; + } + + /** + * Set callback to protect ids with + * + * @param callable $protector + * + * @return $this + */ + public function setIdProtector(callable $protector): self + { + $this->protector = $protector; + + return $this; + } + + /** + * Get whether the toggle is checked + * + * @return bool + */ + public function isChecked(): bool + { + $this->ensureAssembled(); + + return $this->getElement('problems')->isChecked(); + } + + protected function assemble() + { + $this->addElement('checkbox', 'problems', [ + 'class' => 'autosubmit', + 'id' => $this->protectId('problems'), + 'label' => t('Problems Only'), + 'value' => $this->filter !== null + ]); + + $this->add($this->createUidElement()); + } + + private function protectId($id) + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } +} diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php new file mode 100644 index 0000000..b89e729 --- /dev/null +++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php @@ -0,0 +1,406 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Control\SearchBar; + +use Generator; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Model\Behavior\ReRoute; +use Icinga\Module\Icingadb\Model\CustomvarFlat; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor; +use ipl\Html\HtmlElement; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Model; +use ipl\Orm\Relation; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Orm\Relation\HasOne; +use ipl\Orm\Resolver; +use ipl\Orm\UnionModel; +use ipl\Sql\Expression; +use ipl\Sql\Select; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Seq; +use ipl\Web\Control\SearchBar\SearchException; +use ipl\Web\Control\SearchBar\Suggestions; +use PDO; + +class ObjectSuggestions extends Suggestions +{ + use Auth; + use Database; + + /** @var Model */ + protected $model; + + /** @var array */ + protected $customVarSources; + + public function __construct() + { + $this->customVarSources = [ + 'checkcommand' => t('Checkcommand %s', '..<customvar-name>'), + 'eventcommand' => t('Eventcommand %s', '..<customvar-name>'), + 'host' => t('Host %s', '..<customvar-name>'), + 'hostgroup' => t('Hostgroup %s', '..<customvar-name>'), + 'notification' => t('Notification %s', '..<customvar-name>'), + 'notificationcommand' => t('Notificationcommand %s', '..<customvar-name>'), + 'service' => t('Service %s', '..<customvar-name>'), + 'servicegroup' => t('Servicegroup %s', '..<customvar-name>'), + 'timeperiod' => t('Timeperiod %s', '..<customvar-name>'), + 'user' => t('User %s', '..<customvar-name>'), + 'usergroup' => t('Usergroup %s', '..<customvar-name>') + ]; + } + + /** + * Set the model to show suggestions for + * + * @param string|Model $model + * + * @return $this + */ + public function setModel($model): self + { + if (is_string($model)) { + $model = new $model(); + } + + $this->model = $model; + + return $this; + } + + /** + * Get the model to show suggestions for + * + * @return Model + */ + public function getModel(): Model + { + if ($this->model === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->model; + } + + protected function shouldShowRelationFor(string $column): bool + { + if (strpos($column, '.vars.') !== false) { + return false; + } + + $tableName = $this->getModel()->getTableName(); + $columnPath = explode('.', $column); + + switch (count($columnPath)) { + case 3: + if ($columnPath[1] !== 'state' || ! in_array($tableName, ['host', 'service'])) { + return true; + } + + // For host/service state relation columns apply the same rules + case 2: + return $columnPath[0] !== $tableName; + default: + return true; + } + } + + protected function createQuickSearchFilter($searchTerm) + { + $model = $this->getModel(); + $resolver = $model::on($this->getDb())->getResolver(); + + $quickFilter = Filter::any(); + foreach ($model->getSearchColumns() as $column) { + $where = Filter::like($resolver->qualifyColumn($column, $model->getTableName()), $searchTerm); + $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel()); + $quickFilter->add($where); + } + + return $quickFilter; + } + + protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter) + { + $model = $this->getModel(); + $query = $model::on($this->getDb()); + $query->limit(static::DEFAULT_LIMIT); + + if (strpos($column, ' ') !== false) { + // $column may be a label + list($path, $_) = Seq::find( + self::collectFilterColumns($query->getModel(), $query->getResolver()), + $column, + false + ); + if ($path !== null) { + $column = $path; + } + } + + $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableName()); + list($targetPath, $columnName) = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2); + + $isCustomVar = false; + if (substr($targetPath, -5) === '.vars') { + $isCustomVar = true; + $targetPath = substr($targetPath, 0, -4) . 'customvar_flat'; + } + + if (strpos($targetPath, '.') !== false) { + try { + $query->with($targetPath); // TODO: Remove this, once ipl/orm does it as early + } catch (InvalidRelationException $e) { + throw new SearchException(sprintf(t('"%s" is not a valid relation'), $e->getRelation())); + } + } + + if ($isCustomVar) { + $columnPath = $targetPath . '.flatvalue'; + $query->filter(Filter::like($targetPath . '.flatname', $columnName)); + } + + $inputFilter = Filter::like($columnPath, $searchTerm); + $query->columns($columnPath); + $query->orderBy($columnPath); + + // This had so many iterations, if it still doesn't work, consider removing it entirely :( + if ($searchFilter instanceof Filter\None) { + $query->filter($inputFilter); + } elseif ($searchFilter instanceof Filter\All) { + $searchFilter->add($inputFilter); + + // There may be columns part of $searchFilter which target the base table. These must be + // optimized, otherwise they influence what we'll suggest to the user. (i.e. less) + // The $inputFilter on the other hand must not be optimized, which it wouldn't, but since + // we force optimization on its parent chain, we have to negate that. + $searchFilter->metaData()->set('forceOptimization', true); + $inputFilter->metaData()->set('forceOptimization', false); + } else { + $searchFilter = $inputFilter; + } + + $query->filter($searchFilter); + $this->applyRestrictions($query); + + try { + return (new ObjectSuggestionsCursor($query->getDb(), $query->assembleSelect()->distinct())) + ->setFetchMode(PDO::FETCH_COLUMN); + } catch (InvalidColumnException $e) { + throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn())); + } + } + + protected function fetchColumnSuggestions($searchTerm) + { + $model = $this->getModel(); + $query = $model::on($this->getDb()); + + // Ordinary columns first + foreach (self::collectFilterColumns($model, $query->getResolver()) as $columnName => $columnMeta) { + yield $columnName => $columnMeta; + } + + // Custom variables only after the columns are exhausted and there's actually a chance the user sees them + $titleAdded = false; + $parsedArrayVars = []; + foreach ($this->getDb()->select($this->queryCustomvarConfig($searchTerm)) as $customVar) { + $search = $name = $customVar->flatname; + if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) { + $name = substr($search, 0, -(strlen($matches[1]) + 2)); + if (isset($parsedArrayVars[$name])) { + continue; + } + + $parsedArrayVars[$name] = true; + $search = $name . '[*]'; + } + + foreach ($this->customVarSources as $relation => $label) { + if (isset($customVar->$relation)) { + if (! $titleAdded) { + $titleAdded = true; + $this->addHtml(HtmlElement::create( + 'li', + ['class' => static::SUGGESTION_TITLE_CLASS], + t('Custom Variables') + )); + } + + yield $relation . '.vars.' . $search => sprintf($label, $name); + } + } + } + } + + protected function matchSuggestion($path, $label, $searchTerm) + { + if (preg_match('/[_.](id|bin|checksum)$/', $path)) { + // Only suggest exotic columns if the user knows about them + $trimmedSearch = trim($searchTerm, ' *'); + return substr($path, -strlen($trimmedSearch)) === $trimmedSearch; + } + + return parent::matchSuggestion($path, $label, $searchTerm); + } + + /** + * Create a query to fetch all available custom variables matching the given term + * + * @param string $searchTerm + * + * @return Select + */ + protected function queryCustomvarConfig(string $searchTerm): Select + { + $customVars = CustomvarFlat::on($this->getDb()); + $tableName = $customVars->getModel()->getTableName(); + $resolver = $customVars->getResolver(); + + $scalarQueries = []; + $aggregates = ['flatname']; + foreach ($resolver->getRelations($customVars->getModel()) as $name => $relation) { + if (isset($this->customVarSources[$name]) && $relation instanceof BelongsToMany) { + $query = $customVars->createSubQuery( + $relation->getTarget(), + $resolver->qualifyPath($name, $tableName) + ); + + $this->applyRestrictions($query); + + $aggregates[$name] = new Expression("MAX($name)"); + $scalarQueries[$name] = $query->assembleSelect() + ->resetColumns()->columns(new Expression('1')) + ->limit(1); + } + } + + $customVars->columns('flatname'); + $this->applyRestrictions($customVars); + $customVars->filter(Filter::like('flatname', $searchTerm)); + $idColumn = $resolver->qualifyColumn('id', $resolver->getAlias($customVars->getModel())); + $customVars = $customVars->assembleSelect(); + + $customVars->columns($scalarQueries); + $customVars->groupBy($idColumn); + $customVars->limit(static::DEFAULT_LIMIT); + + // This outer query exists only because there's no way to combine aggregates and sub queries (yet) + return (new Select())->columns($aggregates)->from(['results' => $customVars])->groupBy('flatname'); + } + + /** + * Collect all columns of this model and its relations that can be used for filtering + * + * @param Model $model + * @param Resolver $resolver + * + * @return Generator + */ + public static function collectFilterColumns(Model $model, Resolver $resolver): Generator + { + if ($model instanceof UnionModel) { + $models = []; + foreach ($model->getUnions() as $union) { + /** @var Model $unionModel */ + $unionModel = new $union[0](); + $models[$unionModel->getTableName()] = $unionModel; + self::collectRelations($resolver, $unionModel, $models, []); + } + } else { + $models = [$model->getTableName() => $model]; + self::collectRelations($resolver, $model, $models, []); + } + + /** @var Model $targetModel */ + foreach ($models as $path => $targetModel) { + foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) { + yield $path . '.' . $columnName => $definition->getLabel(); + } + } + + foreach ($resolver->getBehaviors($model) as $behavior) { + if ($behavior instanceof ReRoute) { + foreach ($behavior->getRoutes() as $name => $route) { + $relation = $resolver->resolveRelation( + $resolver->qualifyPath($route, $model->getTableName()), + $model + ); + foreach ($resolver->getColumnDefinitions($relation->getTarget()) as $columnName => $definition) { + yield $name . '.' . $columnName => $definition->getLabel(); + } + } + } + } + + if ($model instanceof UnionModel) { + $queries = $model->getUnions(); + $baseModelClass = end($queries)[0]; + $model = new $baseModelClass(); + } + + $foreignMetaDataSources = []; + if (! $model instanceof Host) { + $foreignMetaDataSources[] = 'host.user'; + $foreignMetaDataSources[] = 'host.usergroup'; + } + + if (! $model instanceof Service) { + $foreignMetaDataSources[] = 'service.user'; + $foreignMetaDataSources[] = 'service.usergroup'; + } + + foreach ($foreignMetaDataSources as $path) { + $foreignColumnDefinitions = $resolver->getColumnDefinitions($resolver->resolveRelation( + $resolver->qualifyPath($path, $model->getTableName()), + $model + )->getTarget()); + foreach ($foreignColumnDefinitions as $columnName => $columnDefinition) { + yield "$path.$columnName" => $columnDefinition->getLabel(); + } + } + } + + /** + * Collect all direct relations of the given model + * + * A direct relation is either a direct descendant of the model + * or a descendant of such related in a to-one cardinality. + * + * @param Resolver $resolver + * @param Model $subject + * @param array $models + * @param array $path + */ + protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path) + { + foreach ($resolver->getRelations($subject) as $name => $relation) { + /** @var Relation $relation */ + if ( + empty($path) || ( + ($name === 'state' && $path[count($path) - 1] !== 'last_comment') + || $name === 'last_comment' + || $name === 'notificationcommand' && $path[0] === 'notification' + ) + ) { + $relationPath = [$name]; + if ($relation instanceof HasOne && empty($path)) { + array_unshift($relationPath, $subject->getTableName()); + } + + $relationPath = array_merge($path, $relationPath); + $models[join('.', $relationPath)] = $relation->getTarget(); + self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath); + } + } + } +} diff --git a/library/Icingadb/Web/Control/ViewModeSwitcher.php b/library/Icingadb/Web/Control/ViewModeSwitcher.php new file mode 100644 index 0000000..8068aee --- /dev/null +++ b/library/Icingadb/Web/Control/ViewModeSwitcher.php @@ -0,0 +1,219 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\FormElement\InputElement; +use ipl\Html\HtmlElement; +use ipl\Web\Common\FormUid; +use ipl\Web\Widget\IcingaIcon; + +class ViewModeSwitcher extends Form +{ + use FormUid; + + protected $defaultAttributes = [ + 'class' => 'view-mode-switcher', + 'name' => 'view-mode-switcher' + ]; + + /** @var string Default view mode */ + const DEFAULT_VIEW_MODE = 'common'; + + /** @var string Default view mode param */ + const DEFAULT_VIEW_MODE_PARAM = 'view'; + + /** @var array View mode-icon pairs */ + public static $viewModes = [ + 'minimal' => 'minimal', + 'common' => 'default', + 'detailed' => 'detailed', + 'tabular' => 'tabular' + ]; + + /** @var string */ + protected $defaultViewMode; + + /** @var string */ + protected $method = 'POST'; + + /** @var callable */ + protected $protector; + + /** @var string */ + protected $viewModeParam = self::DEFAULT_VIEW_MODE_PARAM; + + /** + * Get the default mode + * + * @return string + */ + public function getDefaultViewMode(): string + { + return $this->defaultViewMode ?: static::DEFAULT_VIEW_MODE; + } + + /** + * Set the default view mode + * + * @param string $defaultViewMode + * + * @return $this + */ + public function setDefaultViewMode(string $defaultViewMode): self + { + $this->defaultViewMode = $defaultViewMode; + + return $this; + } + + /** + * Get the view mode URL parameter + * + * @return string + */ + public function getViewModeParam(): string + { + return $this->viewModeParam; + } + + /** + * Set the view mode URL parameter + * + * @param string $viewModeParam + * + * @return $this + */ + public function setViewModeParam(string $viewModeParam): self + { + $this->viewModeParam = $viewModeParam; + + return $this; + } + + /** + * Get the view mode + * + * @return string + */ + public function getViewMode(): string + { + $viewMode = $this->getPopulatedValue($this->getViewModeParam(), $this->getDefaultViewMode()); + + if (array_key_exists($viewMode, static::$viewModes)) { + return $viewMode; + } + + return $this->getDefaultViewMode(); + } + + /** + * Set the view mode + * + * @param string $name + * + * @return $this + */ + public function setViewMode(string $name) + { + $this->populate([$this->getViewModeParam() => $name]); + + return $this; + } + + /** + * Set callback to protect ids with + * + * @param callable $protector + * + * @return $this + */ + public function setIdProtector(callable $protector): self + { + $this->protector = $protector; + + return $this; + } + + private function protectId($id) + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } + + protected function assemble() + { + $viewModeParam = $this->getViewModeParam(); + + $this->addElement($this->createUidElement()); + $this->addElement(new HiddenElement($viewModeParam)); + + foreach (static::$viewModes as $viewMode => $icon) { + if ($viewMode === 'tabular') { + continue; + } + + $protectedId = $this->protectId('view-mode-switcher-' . $icon); + $input = new InputElement($viewModeParam, [ + 'class' => 'autosubmit', + 'id' => $protectedId, + 'name' => $viewModeParam, + 'type' => 'radio', + 'value' => $viewMode + ]); + $input->getAttributes()->registerAttributeCallback('checked', function () use ($viewMode) { + return $viewMode === $this->getViewMode(); + }); + + $label = new HtmlElement( + 'label', + Attributes::create([ + 'for' => $protectedId + ]), + new IcingaIcon($icon) + ); + $label->getAttributes()->registerAttributeCallback('title', function () use ($viewMode) { + + return $this->getTitle($viewMode); + }); + + $this->addHtml($input, $label); + } + } + + /** + * Return the title for the view mode when it is active and inactive + * + * @param string $viewMode + * + * @return string Title for the view mode when it is active and inactive + */ + protected function getTitle(string $viewMode): string + { + $active = null; + $inactive = null; + switch ($viewMode) { + case 'minimal': + $active = t('Minimal view active'); + $inactive = t('Switch to minimal view'); + break; + case 'common': + $active = t('Common view active'); + $inactive = t('Switch to common view'); + break; + case 'detailed': + $active = t('Detailed view active'); + $inactive = t('Switch to detailed view'); + break; + } + + return $viewMode === $this->getViewMode() ? $active : $inactive; + } +} diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php new file mode 100644 index 0000000..ad9f07e --- /dev/null +++ b/library/Icingadb/Web/Controller.php @@ -0,0 +1,542 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web; + +use Exception; +use Generator; +use GuzzleHttp\Psr7\ServerRequest; +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Application\Version; +use Icinga\Application\Web; +use Icinga\Data\ConfigObject; +use Icinga\Date\DateFormatter; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\Http\HttpBadRequestException; +use Icinga\Exception\Json\JsonDecodeException; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\SearchControls; +use Icinga\Module\Icingadb\Data\CsvResultSet; +use Icinga\Module\Icingadb\Data\JsonResultSet; +use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher; +use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher; +use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable; +use Icinga\Module\Pdfexport\PrintableHtmlDocument; +use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport; +use Icinga\Security\SecurityException; +use Icinga\User\Preferences; +use Icinga\User\Preferences\PreferencesStore; +use Icinga\Util\Environment; +use Icinga\Util\Json; +use ipl\Html\Html; +use ipl\Html\ValidHtml; +use ipl\Orm\Query; +use ipl\Orm\UnionQuery; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseItemList; +use ipl\Web\Common\BaseItemTable; +use ipl\Web\Compat\CompatController; +use ipl\Web\Control\LimitControl; +use ipl\Web\Control\PaginationControl; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; + +class Controller extends CompatController +{ + use Auth; + use Database; + use SearchControls; + + /** @var Filter\Rule Filter from query string parameters */ + private $filter; + + /** @var string|null */ + private $format; + + /** @var bool */ + private $formatProcessed = false; + + /** + * Get the filter created from query string parameters + * + * @return Filter\Rule + */ + public function getFilter(): Filter\Rule + { + if ($this->filter === null) { + $this->filter = QueryString::parse((string) $this->params); + } + + return $this->filter; + } + + /** + * Create column control + * + * @param Query $query + * @param ViewModeSwitcher $viewModeSwitcher + * + * @return array provided columns + * + * @throws HttpBadRequestException + */ + public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwitcher): array + { + // All of that is essentially what `ColumnControl::apply()` should do + $viewMode = $this->getRequest()->getUrl()->getParam($viewModeSwitcher->getViewModeParam()); + $columnsDef = $this->params->shift('columns'); + if (! $columnsDef) { + if ($viewMode === 'tabular') { + $this->httpBadRequest('Missing parameter "columns"'); + } + + return []; + } + + $columns = []; + foreach (explode(',', $columnsDef) as $column) { + if ($column = trim($column)) { + $columns[] = $column; + } + } + + $query->withColumns($columns); + + if (! $viewMode) { + $viewModeSwitcher->setViewMode('tabular'); + } + + // For now this also returns the columns, but they should be accessible + // by calling `ColumnControl::getColumns()` in the future + return $columns; + } + + /** + * Create and return the ViewModeSwitcher + * + * This automatically shifts the view mode URL parameter from {@link $params}. + * + * @param PaginationControl $paginationControl + * @param LimitControl $limitControl + * @param bool $verticalPagination + * + * @return ViewModeSwitcher|GridViewModeSwitcher + */ + public function createViewModeSwitcher( + PaginationControl $paginationControl, + LimitControl $limitControl, + bool $verticalPagination = false + ): ViewModeSwitcher { + $controllerName = $this->getRequest()->getControllerName(); + + // TODO: Make this configurable somehow. The route shouldn't be checked to choose the view modes! + if ($controllerName === 'hostgroups' || $controllerName === 'servicegroups') { + $viewModeSwitcher = new GridViewModeSwitcher(); + } else { + $viewModeSwitcher = new ViewModeSwitcher(); + } + + $viewModeSwitcher->setIdProtector([$this->getRequest(), 'protectId']); + + $user = $this->Auth()->getUser(); + if (($preferredModes = $user->getAdditional('icingadb.view_modes')) === null) { + try { + $preferredModes = Json::decode( + $user->getPreferences()->getValue('icingadb', 'view_modes', '[]'), + true + ); + } catch (JsonDecodeException $e) { + Logger::error('Failed to load preferred view modes for user "%s": %s', $user->getUsername(), $e); + $preferredModes = []; + } + + $user->setAdditional('icingadb.view_modes', $preferredModes); + } + + $requestRoute = $this->getRequest()->getUrl()->getPath(); + if (isset($preferredModes[$requestRoute])) { + $viewModeSwitcher->setDefaultViewMode($preferredModes[$requestRoute]); + } + + $viewModeSwitcher->populate([ + $viewModeSwitcher->getViewModeParam() => $this->params->shift($viewModeSwitcher->getViewModeParam()) + ]); + + $session = $this->Window()->getSessionNamespace( + 'icingadb-viewmode-' . $this->Window()->getContainerId() + ); + + $viewModeSwitcher->on( + ViewModeSwitcher::ON_SUCCESS, + function (ViewModeSwitcher $viewModeSwitcher) use ( + $user, + $preferredModes, + $paginationControl, + $verticalPagination, + &$session + ) { + $viewMode = $viewModeSwitcher->getValue($viewModeSwitcher->getViewModeParam()); + $requestUrl = Url::fromRequest(); + + $preferredModes[$requestUrl->getPath()] = $viewMode; + $user->setAdditional('icingadb.view_modes', $preferredModes); + + try { + $preferencesStore = PreferencesStore::create(new ConfigObject([ + //TODO: Don't set store key as it will no longer be needed once we drop support for + // lower version of icingaweb2 then v2.11. + //https://github.com/Icinga/icingaweb2/pull/4765 + 'store' => Config::app()->get('global', 'config_backend', 'db'), + 'resource' => Config::app()->get('global', 'config_resource') + ]), $user); + $preferencesStore->load(); + $preferencesStore->save( + new Preferences(['icingadb' => ['view_modes' => Json::encode($preferredModes)]]) + ); + } catch (Exception $e) { + Logger::error('Failed to save preferred view mode for user "%s": %s', $user->getUsername(), $e); + } + + $pageParam = $paginationControl->getPageParam(); + $limitParam = LimitControl::DEFAULT_LIMIT_PARAM; + $currentPage = $paginationControl->getCurrentPageNumber(); + + $requestUrl->setParam($viewModeSwitcher->getViewModeParam(), $viewMode); + if (! $requestUrl->hasParam($limitParam)) { + if ($viewMode === 'minimal' || $viewMode === 'grid') { + $session->set('previous_page', $currentPage); + $session->set('request_path', $requestUrl->getPath()); + + $limit = $paginationControl->getLimit(); + if (! $verticalPagination) { + // We are computing it based on the first element being rendered on this current page + $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit * 2)) + 1); + } else { + $currentPage = (int) (round($currentPage * $limit / ($limit * 2))); + } + + $session->set('current_page', $currentPage); + } elseif ( + $viewModeSwitcher->getDefaultViewMode() === 'minimal' + || $viewModeSwitcher->getDefaultViewMode() === 'grid' + ) { + $limit = $paginationControl->getLimit(); + if ($currentPage === $session->get('current_page')) { + // No other page numbers have been selected, i.e the user only + // switches back and forth without changing the page numbers + $currentPage = $session->get('previous_page'); + } elseif (! $verticalPagination) { + $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit / 2)) + 1); + } else { + $currentPage = (int) (floor($currentPage * $limit / ($limit / 2))); + } + + $session->clear(); + } + + if (($requestUrl->hasParam($pageParam) && $currentPage > 1) || $currentPage > 1) { + $requestUrl->setParam($pageParam, $currentPage); + } else { + $requestUrl->remove($pageParam); + } + } + + $this->redirectNow($requestUrl); + } + )->handleRequest(ServerRequest::fromGlobals()); + + $viewMode = $viewModeSwitcher->getViewMode(); + if ($viewMode === 'minimal' || $viewMode === 'grid') { + $hasLimitParam = Url::fromRequest()->hasParam($limitControl->getLimitParam()); + + if ($paginationControl->getDefaultPageSize() <= LimitControl::DEFAULT_LIMIT && ! $hasLimitParam) { + $paginationControl->setDefaultPageSize($paginationControl->getDefaultPageSize() * 2); + $limitControl->setDefaultLimit($limitControl->getDefaultLimit() * 2); + + $paginationControl->apply(); + } + } + + $requestPath = $session->get('request_path'); + if ($requestPath && $requestPath !== $requestRoute) { + $session->clear(); + } + + return $viewModeSwitcher; + } + + /** + * Process a search request + * + * @param Query $query + * @param array $additionalColumns + * + * @return void + */ + public function handleSearchRequest(Query $query, array $additionalColumns = []) + { + $q = trim($this->params->shift('q', ''), ' *'); + if (! $q) { + return; + } + + $filter = Filter::any(); + $this->prepareSearchFilter($query, $q, $filter, $additionalColumns); + + $redirectUrl = Url::fromRequest(); + $redirectUrl->setParams($this->params)->setFilter($filter); + + $this->getResponse()->redirectAndExit($redirectUrl); + } + + /** + * Prepare the given search filter + * + * @param Query $query + * @param string $search + * @param Filter\Any $filter + * @param array $additionalColumns + * + * @return void + */ + protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter, array $additionalColumns) + { + $columns = array_merge($query->getModel()->getSearchColumns(), $additionalColumns); + foreach ($columns as $column) { + $filter->add(Filter::like( + $query->getResolver()->qualifyColumn($column, $query->getModel()->getTableName()), + "*$search*" + )); + } + } + + /** + * Require permission to access the given route + * + * @param string $name If NULL, the current controller name is used + * + * @throws SecurityException + */ + public function assertRouteAccess(string $name = null) + { + if (! $name) { + $name = $this->getRequest()->getControllerName(); + } + + if (! $this->isPermittedRoute($name)) { + throw new SecurityException('No permission to access this route'); + } + } + + public function export(Query ...$queries) + { + if ($this->format === 'sql') { + foreach ($queries as $query) { + list($sql, $values) = $query->getDb()->getQueryBuilder()->assembleSelect($query->assembleSelect()); + + $unused = []; + foreach ($values as $value) { + $pos = strpos($sql, '?'); + if ($pos !== false) { + if (is_string($value)) { + $value = "'" . $value . "'"; + } + + $sql = substr_replace($sql, $value, $pos, 1); + } else { + $unused[] = $value; + } + } + + if (!empty($unused)) { + $sql .= ' /* Unused values: "' . join('", "', $unused) . '" */'; + } + + $this->content->add(Html::tag('pre', $sql)); + } + + return true; + } + + // It only makes sense to export a single result to CSV or JSON + $query = $queries[0]; + + // No matter the format, a limit should only apply if set + if ($this->format !== null) { + $query->limit(Url::fromRequest()->getParam('limit')); + } + + if ($this->format === 'json' || $this->format === 'csv') { + $response = $this->getResponse(); + $fileName = $this->view->title; + + ob_end_clean(); + Environment::raiseExecutionTime(); + + if ($this->format === 'json') { + $response + ->setHeader('Content-Type', 'application/json') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'attachment; filename=' . $fileName . '.json' + ) + ->sendResponse(); + + JsonResultSet::stream($query); + } else { + $response + ->setHeader('Content-Type', 'text/csv') + ->setHeader('Cache-Control', 'no-store') + ->setHeader( + 'Content-Disposition', + 'attachment; filename=' . $fileName . '.csv' + ) + ->sendResponse(); + + CsvResultSet::stream($query); + } + } + + $this->getTabs()->enableDataExports(); + } + + /** + * @todo Remove once support for Icinga Web 2 v2.9.x is dropped + */ + protected function sendAsPdf() + { + if (! Icinga::app()->getModuleManager()->has('pdfexport')) { + throw new ConfigurationError('The pdfexport module is required for exports to PDF'); + } + + if (version_compare(Version::VERSION, '2.10.0', '>=')) { + parent::sendAsPdf(); + return; + } + + putenv('ICINGAWEB_EXPORT_FORMAT=pdf'); + Environment::raiseMemoryLimit('512M'); + Environment::raiseExecutionTime(300); + + $time = DateFormatter::formatDateTime(time()); + + $doc = (new PrintableHtmlDocument()) + ->setTitle($this->view->title ?? '') + ->setHeader(Html::wantHtml([ + Html::tag('span', ['class' => 'title']), + Html::tag('time', null, $time) + ])) + ->setFooter(Html::wantHtml([ + Html::tag('span', null, [ + t('Page') . ' ', + Html::tag('span', ['class' => 'pageNumber']), + ' / ', + Html::tag('span', ['class' => 'totalPages']) + ]), + Html::tag('p', null, Url::fromRequest()->setParams($this->params)) + ])) + ->addHtml($this->content); + $doc->getAttributes()->add('class', 'icinga-module module-icingadb'); + + Pdfexport::first()->streamPdfFromHtml($doc, sprintf( + '%s-%s', + $this->view->title ?: $this->getRequest()->getActionName(), + $time + )); + } + + public function dispatch($action) + { + // Notify helpers of action preDispatch state + $this->_helper->notifyPreDispatch(); + + $this->preDispatch(); + + if ($this->getRequest()->isDispatched()) { + // If pre-dispatch hooks introduced a redirect then stop dispatch + // @see ZF-7496 + if (! $this->getResponse()->isRedirect()) { + $interceptable = $this->$action(); + if ($interceptable instanceof Generator) { + foreach ($interceptable as $stopSignal) { + if ($stopSignal === true) { + $this->formatProcessed = true; + break; + } + } + } + } + $this->postDispatch(); + } + + // whats actually important here is that this action controller is + // shutting down, regardless of dispatching; notify the helpers of this + // state + $this->_helper->notifyPostDispatch(); + } + + protected function addContent(ValidHtml $content) + { + if ($content instanceof BaseItemList || $content instanceof BaseItemTable) { + $this->content->getAttributes()->add('class', 'full-width'); + } elseif ($content instanceof StateItemTable) { + $this->content->getAttributes()->add('class', 'full-height'); + } + + return parent::addContent($content); + } + + public function filter(Query $query, Filter\Rule $filter = null): self + { + if ($this->format !== 'sql' || $this->hasPermission('config/authentication/roles/show')) { + $this->applyRestrictions($query); + } + + if ($query instanceof UnionQuery) { + foreach ($query->getUnions() as $query) { + $query->filter($filter ?: $this->getFilter()); + } + } else { + $query->filter($filter ?: $this->getFilter()); + } + + return $this; + } + + public function preDispatch() + { + parent::preDispatch(); + + $this->format = $this->params->shift('format'); + } + + public function postDispatch() + { + if (! $this->formatProcessed && $this->format !== null && $this->format !== 'pdf') { + // The purpose of this is not only to show that a requested format isn't supported. + // It's main purpose is to not allow to bypass restrictions with `?format=sql` as + // it may be possible that an action applies restrictions, but doesn't support any + // output formats. Since the restrictions are bypassed in method `$this->filter()` + // for the SQL output format and the actual format processing is part of a different + // method (`$this->export()`) which needs to be called explicitly by an action, + // it's otherwise possible for bad individuals to access unrestricted data. + $this->httpBadRequest(t('This route does not support the requested output format')); + } + + parent::postDispatch(); + } + + protected function moduleInit() + { + /** @var Web $app */ + $app = Icinga::app(); + $app->getFrontController() + ->getPlugin('Zend_Controller_Plugin_ErrorHandler') + ->setErrorHandlerModule('icingadb'); + } +} diff --git a/library/Icingadb/Web/Navigation/Action.php b/library/Icingadb/Web/Navigation/Action.php new file mode 100644 index 0000000..d02f933 --- /dev/null +++ b/library/Icingadb/Web/Navigation/Action.php @@ -0,0 +1,134 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Navigation; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Macros; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Web\Navigation\NavigationItem; +use ipl\Web\Url; + +class Action extends NavigationItem +{ + use Auth; + use Macros; + + /** + * Whether this action's macros were already resolved + * + * @var bool + */ + protected $resolved = false; + + /** + * This action's object + * + * @var Host|Service + */ + protected $object; + + /** + * The filter to use when being asked whether to render this action + * + * @var string + */ + protected $filter; + + /** + * This action's raw url attribute + * + * @var string + */ + protected $rawUrl; + + /** + * Set this action's object + * + * @param Host|Service $object + * + * @return $this + */ + public function setObject($object): self + { + $this->object = $object; + + return $this; + } + + /** + * Get this action's object + * + * @return Host|Service + */ + protected function getObject() + { + return $this->object; + } + + /** + * Set the filter to use when being asked whether to render this action + * + * @param string $filter + * + * @return $this + */ + public function setFilter(string $filter): self + { + $this->filter = $filter; + + return $this; + } + + /** + * Get the filter to use when being asked whether to render this action + * + * @return ?string + */ + public function getFilter(): ?string + { + return $this->filter; + } + + /** + * Set this item's url + * + * @param \Icinga\Web\Url|string $url + * + * @return $this + */ + public function setUrl($url): self + { + if (is_string($url)) { + $this->rawUrl = $url; + } else { + parent::setUrl($url); + } + + return $this; + } + + public function getUrl(): ?\Icinga\Web\Url + { + $url = parent::getUrl(); + if (! $this->resolved && $url === null && $this->rawUrl !== null) { + $this->setUrl(Url::fromPath($this->expandMacros($this->rawUrl, $this->getObject()))); + $this->resolved = true; + return parent::getUrl(); + } else { + return $url; + } + } + + public function getRender(): bool + { + if ($this->render === null) { + $filter = $this->getFilter(); + $this->render = ! $filter || $this->isMatchedOn($filter, $this->getObject()); + } + + return $this->render; + } +} diff --git a/library/Icingadb/Web/Navigation/IcingadbHostAction.php b/library/Icingadb/Web/Navigation/IcingadbHostAction.php new file mode 100644 index 0000000..a5fc256 --- /dev/null +++ b/library/Icingadb/Web/Navigation/IcingadbHostAction.php @@ -0,0 +1,9 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Navigation; + +class IcingadbHostAction extends Action +{ +} diff --git a/library/Icingadb/Web/Navigation/IcingadbServiceAction.php b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php new file mode 100644 index 0000000..d623951 --- /dev/null +++ b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php @@ -0,0 +1,9 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Navigation; + +class IcingadbServiceAction extends Action +{ +} diff --git a/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php new file mode 100644 index 0000000..fc64c7d --- /dev/null +++ b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php @@ -0,0 +1,35 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Navigation\Renderer; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\HoststateSummary; +use ipl\Web\Url; + +class HostProblemsBadge extends ProblemsBadge +{ + use Auth; + + protected function fetchProblemsCount() + { + $summary = HoststateSummary::on($this->getDb()); + $this->applyRestrictions($summary); + $count = (int) $summary->first()->hosts_down_unhandled; + if ($count) { + $this->setTitle(sprintf( + tp('One unhandled host down', '%d unhandled hosts down', $count), + $count + )); + } + + return $count; + } + + protected function getUrl(): Url + { + return Links::hosts()->setParams(['host.state.is_problem' => 'y', 'sort' => 'host.state.severity desc']); + } +} diff --git a/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php new file mode 100644 index 0000000..658fa1c --- /dev/null +++ b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php @@ -0,0 +1,173 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Navigation\Renderer; + +use Exception; +use Icinga\Application\Logger; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Web\Navigation\NavigationItem; +use Icinga\Web\Navigation\Renderer\NavigationItemRenderer; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBadge; + +abstract class ProblemsBadge extends NavigationItemRenderer +{ + use Database; + + const STATE_CRITICAL = 'critical'; + const STATE_UNKNOWN = 'unknown'; + + /** @var int Count cache */ + protected $count; + + /** @var string State text */ + protected $state; + + /** @var string Title */ + protected $title; + + protected $linkDisabled; + + abstract protected function fetchProblemsCount(); + + abstract protected function getUrl(); + + public function getProblemsCount() + { + if ($this->count === null) { + try { + $count = $this->fetchProblemsCount(); + } catch (Exception $e) { + Logger::debug($e); + + $this->count = 1; + + $this->setState(static::STATE_UNKNOWN); + $this->setTitle($e->getMessage()); + + return $this->count; + } + + $this->count = $this->round($count); + + $this->setState(static::STATE_CRITICAL); + } + + return $this->count; + } + + /** + * Set the state text + * + * @param string $state + * + * @return $this + */ + public function setState(string $state): self + { + $this->state = $state; + + return $this; + } + + /** + * Get the state text + * + * @return string + */ + public function getState(): string + { + if ($this->state === null) { + throw new \LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->state; + } + + /** + * Set the title + * + * @param string $title + * + * @return $this + */ + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + /** + * Get the title + * + * @return ?string + */ + public function getTitle() + { + return $this->title; + } + + public function render(NavigationItem $item = null): string + { + if ($item === null) { + $item = $this->getItem(); + } + + $item->setCssClass('badge-nav-item icinga-module module-icingadb'); + + $html = new HtmlDocument(); + + $badge = $this->createBadge(); + if ($badge !== null) { + if ($this->linkDisabled) { + $badge->addAttributes(['class' => 'disabled']); + $this->setEscapeLabel(false); + $label = $this->view()->escape($item->getLabel()); + $item->setLabel($badge . $label); + } else { + $html->add(new Link($badge, $this->getUrl(), ['title' => $this->getTitle()])); + } + } + + return $html + ->prepend(new HtmlString(parent::render($item))) + ->render(); + } + + protected function createBadge() + { + $count = $this->getProblemsCount(); + + if ($count) { + return (new StateBadge($count, $this->getState())) + ->addAttributes(['class' => 'badge', 'title' => $this->getTitle()]); + } + + return null; + } + + protected function round($count) + { + if ($count > 1000000) { + $count = round($count, -6) / 1000000 . 'M'; + } elseif ($count > 1000) { + $count = round($count, -3) / 1000 . 'k'; + } + + return $count; + } + + public function disableLink() + { + $this->linkDisabled = true; + + return $this; + } +} diff --git a/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php new file mode 100644 index 0000000..b2f2cae --- /dev/null +++ b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php @@ -0,0 +1,36 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Navigation\Renderer; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\ServicestateSummary; +use ipl\Web\Url; + +class ServiceProblemsBadge extends ProblemsBadge +{ + use Auth; + + protected function fetchProblemsCount() + { + $summary = ServicestateSummary::on($this->getDb()); + $this->applyRestrictions($summary); + $count = (int) $summary->first()->services_critical_unhandled; + if ($count) { + $this->setTitle(sprintf( + tp('One unhandled service critical', '%d unhandled services critical', $count), + $count + )); + } + + return $count; + } + + protected function getUrl(): Url + { + return Links::services() + ->setParams(['service.state.is_problem' => 'y', 'sort' => 'service.state.severity desc']); + } +} diff --git a/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php new file mode 100644 index 0000000..703db65 --- /dev/null +++ b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php @@ -0,0 +1,66 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Web\Navigation\Renderer; + +use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer; + +class TotalProblemsBadge extends BadgeNavigationItemRenderer +{ + /** + * Cached count + * + * @var int + */ + protected $count; + + /** + * State to severity map + * + * @var array + */ + protected static $stateSeverityMap = [ + self::STATE_OK => 0, + self::STATE_PENDING => 1, + self::STATE_UNKNOWN => 2, + self::STATE_WARNING => 3, + self::STATE_CRITICAL => 4, + ]; + + /** + * Severity to state map + * + * @var array + */ + protected static $severityStateMap = [ + self::STATE_OK, + self::STATE_PENDING, + self::STATE_UNKNOWN, + self::STATE_WARNING, + self::STATE_CRITICAL + ]; + + public function getCount() + { + if ($this->count === null) { + $countMap = array_fill(0, 5, 0); + $maxSeverity = 0; + foreach ($this->getItem()->getChildren() as $child) { + $renderer = $child->getRenderer(); + if ($renderer instanceof ProblemsBadge) { + $count = $renderer->getProblemsCount(); + if ($count) { + $severity = static::$stateSeverityMap[$renderer->getState()]; + $countMap[$severity] += $count; + $maxSeverity = max($maxSeverity, $severity); + } + } + } + $this->count = $countMap[$maxSeverity]; + $this->state = static::$severityStateMap[$maxSeverity]; + } + + return $this->count; + } +} diff --git a/library/Icingadb/Widget/AttemptBall.php b/library/Icingadb/Widget/AttemptBall.php new file mode 100644 index 0000000..e57c59c --- /dev/null +++ b/library/Icingadb/Widget/AttemptBall.php @@ -0,0 +1,31 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; + +/** + * Visually represents one single check attempt. + */ +class AttemptBall extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'ball']; + + /** + * Create a new attempt ball + * + * @param bool $taken Whether the attempt was taken + */ + public function __construct(bool $taken = false) + { + if ($taken) { + $this->addAttributes(['class' => 'ball-size-s taken']); + } else { + $this->addAttributes(['class' => 'ball-size-xs']); + } + } +} diff --git a/library/Icingadb/Widget/CheckAttempt.php b/library/Icingadb/Widget/CheckAttempt.php new file mode 100644 index 0000000..cf12de3 --- /dev/null +++ b/library/Icingadb/Widget/CheckAttempt.php @@ -0,0 +1,54 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormattedString; + +/** + * Visually represents the check attempts taken out of max check attempts. + */ +class CheckAttempt extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'check-attempt']; + + /** @var int Current attempt */ + protected $attempt; + + /** @var int Max check attempts */ + protected $maxAttempts; + + /** + * Create a new check attempt widget + * + * @param int $attempt Current check attempt + * @param int $maxAttempts Max check attempts + */ + public function __construct(int $attempt, int $maxAttempts) + { + $this->attempt = $attempt; + $this->maxAttempts = $maxAttempts; + } + + protected function assemble() + { + if ($this->attempt == $this->maxAttempts) { + return; + } + + if ($this->maxAttempts > 5) { + $this->add(FormattedString::create('%d/%d', $this->attempt, $this->maxAttempts)); + } else { + for ($i = 0; $i < $this->attempt; ++$i) { + $this->add(new AttemptBall(true)); + } + for ($i = $this->attempt; $i < $this->maxAttempts; ++$i) { + $this->add(new AttemptBall()); + } + } + } +} diff --git a/library/Icingadb/Widget/Detail/CheckStatistics.php b/library/Icingadb/Widget/Detail/CheckStatistics.php new file mode 100644 index 0000000..8a826d5 --- /dev/null +++ b/library/Icingadb/Widget/Detail/CheckStatistics.php @@ -0,0 +1,373 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Date\DateFormatter; +use Icinga\Module\Icingadb\Widget\CheckAttempt; +use Icinga\Util\Format; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormattedString; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Web\Common\Card; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Compat\StyleWithNonce; +use ipl\Web\Widget\TimeAgo; +use ipl\Web\Widget\TimeSince; +use ipl\Web\Widget\TimeUntil; +use ipl\Web\Widget\VerticalKeyValue; + +class CheckStatistics extends Card +{ + const TOP_LEFT_BUBBLE_FLAG = <<<'SVG' +<svg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'> + <path class='bg' d='M0 0L13 13L3.15334e-06 13L0 0Z'/> + <path class='border' fill-rule='evenodd' clip-rule='evenodd' + d='M0 0L3.3959e-06 14L14 14L0 0ZM1 2.41421L1 13L11.5858 13L1 2.41421Z'/> +</svg> +SVG; + + const TOP_RIGHT_BUBBLE_FLAG = <<<'SVG' +<svg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'> + <path class='bg' d="M12 0L-1 13L12 13L12 0Z"/> + <path class='border' fill-rule="evenodd" clip-rule="evenodd" + d="M12 0L12 14L-2 14L12 0ZM11 2.41421L11 13L0.414213 13L11 2.41421Z"/> +</svg> +SVG; + + protected $object; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => ['progress-bar', 'check-statistics']]; + + public function __construct($object) + { + $this->object = $object; + } + + protected function assembleBody(BaseHtmlElement $body) + { + $styleElement = (new StyleWithNonce()) + ->setModule('icingadb'); + + $hPadding = 10; + $durationScale = 80; + $checkInterval = $this->getCheckInterval(); + + $timeline = new HtmlElement('div', Attributes::create(['class' => ['check-timeline', 'timeline']])); + $above = new HtmlElement('ul', Attributes::create(['class' => 'above'])); + $below = new HtmlElement('ul', Attributes::create(['class' => 'below'])); + $progressBar = new HtmlElement('div', Attributes::create(['class' => 'bar'])); + $overdueBar = null; + + $now = time(); + $executionTime = ($this->object->state->execution_time / 1000) + ($this->object->state->latency / 1000); + + $nextCheckTime = $this->object->state->next_check !== null && ! $this->isChecksDisabled() + ? $this->object->state->next_check->getTimestamp() + : null; + if ($this->object->state->is_overdue) { + $nextCheckTime = $this->object->state->next_update->getTimestamp(); + + $durationScale = 60; + + $overdueBar = new HtmlElement( + 'div', + Attributes::create(['class' => 'timeline-overlay']), + new HtmlElement('div', Attributes::create(['class' => 'now'])) + ); + + $above->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'now']), + new HtmlElement( + 'div', + Attributes::create(['class' => 'bubble']), + new HtmlElement('strong', null, Text::create(t('Now'))) + ) + )); + + $this->getAttributes()->add('class', 'check-overdue'); + } else { + $progressBar->addHtml(new HtmlElement('div', Attributes::create(['class' => 'now']))); + } + + if ($nextCheckTime !== null && ! $this->object->state->is_overdue && $nextCheckTime < $now) { + // If the next check is already in the past but not overdue, it means the check is probably running. + // Icinga only updates the state once the check reports a result, that's why we have to simulate the + // execution start and end time, as well as the next check time. + $lastUpdateTime = $nextCheckTime; + $nextCheckTime = $this->object->state->next_update->getTimestamp() - $executionTime; + $executionEndTime = $lastUpdateTime + $executionTime; + } else { + $lastUpdateTime = $this->object->state->last_update !== null + ? $this->object->state->last_update->getTimestamp() - $executionTime + : null; + $executionEndTime = $this->object->state->last_update !== null + ? $this->object->state->last_update->getTimestamp() + : null; + } + + if ($this->object->state->is_overdue) { + $leftNow = 100; + } elseif ($nextCheckTime === null) { + $leftNow = 0; + } elseif (! $this->object->state->is_reachable && time() - $executionEndTime > $checkInterval * 2) { + // We have no way of knowing whether the dependency pauses check scheduling. + // The only way to detect this, is to measure how old the last update is. + $nextCheckTime = null; + $leftNow = 0; + } elseif ($nextCheckTime - $lastUpdateTime <= 0) { + $leftNow = 0; + } else { + $leftNow = 100 * (1 - ($nextCheckTime - time()) / ($nextCheckTime - $lastUpdateTime)); + if ($leftNow > 100) { + $leftNow = 100; + } elseif ($leftNow < 0) { + $leftNow = 0; + } + } + + $styleElement->addFor($progressBar, ['width' => sprintf('%F%%', $leftNow)]); + + $leftExecutionEnd = $nextCheckTime !== null && $nextCheckTime - $lastUpdateTime > 0 ? $durationScale * ( + 1 - ($nextCheckTime - $executionEndTime) / ($nextCheckTime - $lastUpdateTime) + ) : 0; + + $markerLast = new HtmlElement('div', Attributes::create([ + 'class' => ['highlighted', 'marker', 'left'], + 'title' => $lastUpdateTime !== null ? DateFormatter::formatDateTime($lastUpdateTime) : null + ])); + $markerNext = new HtmlElement('div', Attributes::create([ + 'class' => ['highlighted', 'marker', 'right'], + 'title' => $nextCheckTime !== null ? DateFormatter::formatDateTime($nextCheckTime) : null + ])); + $markerExecutionEnd = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']])); + $styleElement->addFor($markerExecutionEnd, [ + 'left' => sprintf('%F%%', $hPadding + $leftExecutionEnd) + ]); + + $progress = new HtmlElement('div', Attributes::create([ + 'class' => ['progress', time() < $executionEndTime ? 'running' : null] + ]), $progressBar); + if ($nextCheckTime !== null) { + $progress->addAttributes([ + 'data-animate-progress' => true, + 'data-start-time' => $lastUpdateTime, + 'data-end-time' => $nextCheckTime, + 'data-switch-after' => $executionTime, + 'data-switch-class' => 'running' + ]); + } + + $timeline->addHtml( + $progress, + $markerLast, + $markerExecutionEnd, + $markerNext + )->add($overdueBar); + + $executionStart = new HtmlElement( + 'li', + Attributes::create(['class' => 'left']), + new HtmlElement( + 'div', + Attributes::create(['class' => ['bubble', 'upwards', 'top-right-aligned']]), + new VerticalKeyValue( + t('Execution Start'), + $lastUpdateTime ? new TimeAgo($lastUpdateTime) : t('PENDING') + ), + HtmlString::create(self::TOP_RIGHT_BUBBLE_FLAG) + ) + ); + $executionEnd = new HtmlElement( + 'li', + Attributes::create(['class' => 'positioned']), + new HtmlElement( + 'div', + Attributes::create(['class' => ['bubble', 'upwards', 'top-left-aligned']]), + new VerticalKeyValue( + t('Execution End'), + $executionEndTime !== null + ? ($executionEndTime > $now + ? new TimeUntil($executionEndTime) + : new TimeAgo($executionEndTime)) + : t('PENDING') + ), + HtmlString::create(self::TOP_LEFT_BUBBLE_FLAG) + ) + ); + + $styleElement->addFor($executionEnd, ['left' => sprintf('%F%%', $hPadding + $leftExecutionEnd)]); + + $intervalLine = new HtmlElement( + 'li', + Attributes::create(['class' => 'interval-line']), + new VerticalKeyValue(t('Interval'), Format::seconds($checkInterval)) + ); + + $styleElement->addFor($intervalLine, [ + 'left' => sprintf('%F%%', $hPadding + $leftExecutionEnd), + 'width' => sprintf('%F%%', $durationScale - $leftExecutionEnd) + ]); + + $executionLine = new HtmlElement( + 'li', + Attributes::create(['class' => ['interval-line', 'execution-line']]), + new VerticalKeyValue( + sprintf('%s / %s', t('Execution Time'), t('Latency')), + FormattedString::create( + '%s / %s', + $this->object->state->execution_time !== null + ? Format::seconds($this->object->state->execution_time / 1000) + : (new EmptyState(t('n. a.')))->setTag('span'), + $this->object->state->latency !== null + ? Format::seconds($this->object->state->latency / 1000) + : (new EmptyState(t('n. a.')))->setTag('span') + ) + ) + ); + + $styleElement->addFor($executionLine, [ + 'left' => sprintf('%F%%', $hPadding), + 'width' => sprintf('%F%%', $leftExecutionEnd) + ]); + + if ($executionEndTime !== null) { + $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'start']))); + $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'end']))); + } + + if ($this->isChecksDisabled()) { + $nextCheckBubbleContent = new VerticalKeyValue( + t('Next Check'), + t('n.a') + ); + + $this->addAttributes(['class' => 'checks-disabled']); + } else { + $nextCheckBubbleContent = $this->object->state->is_overdue + ? new VerticalKeyValue(t('Overdue'), new TimeSince($nextCheckTime)) + : new VerticalKeyValue( + t('Next Check'), + $nextCheckTime !== null + ? ($nextCheckTime > $now + ? new TimeUntil($nextCheckTime) + : new TimeAgo($nextCheckTime)) + : t('PENDING') + ); + } + + $nextCheck = new HtmlElement( + 'li', + Attributes::create(['class' => 'right']), + new HtmlElement( + 'div', + Attributes::create(['class' => ['bubble', 'upwards']]), + $nextCheckBubbleContent + ) + ); + + $above->addHtml($executionLine); + + $below->addHtml( + $executionStart, + $executionEnd, + $intervalLine, + $nextCheck + ); + + $body->addHtml($above, $timeline, $below, $styleElement); + } + + /** + * Checks if both active and passive checks are disabled + * + * @return bool + */ + protected function isChecksDisabled(): bool + { + return ! ($this->object->active_checks_enabled || $this->object->passive_checks_enabled); + } + + protected function assembleHeader(BaseHtmlElement $header) + { + $checkSource = (new EmptyState(t('n. a.')))->setTag('span'); + if ($this->object->state->check_source) { + $checkSource = Text::create($this->object->state->check_source); + } + + $header->addHtml( + new VerticalKeyValue(t('Command'), $this->object->checkcommand_name), + new VerticalKeyValue( + t('Scheduling Source'), + $this->object->state->scheduling_source ?? (new EmptyState(t('n. a.')))->setTag('span') + ) + ); + + if ($this->object->timeperiod->id) { + $header->addHtml(new VerticalKeyValue( + t('Timeperiod'), + $this->object->timeperiod->display_name ?? $this->object->timeperiod->name + )); + } + + $header->addHtml( + new VerticalKeyValue( + t('Attempts'), + new CheckAttempt((int) $this->object->state->check_attempt, (int) $this->object->max_check_attempts) + ), + new VerticalKeyValue(t('Check Source'), $checkSource) + ); + } + + /** + * Get the active `check_interval` OR `check_retry_interval` + * + * @return int + */ + protected function getCheckInterval(): int + { + if (! ($this->object->state->is_problem && $this->object->state->state_type === 'soft')) { + return $this->object->check_interval; + } + + $delay = ($this->object->state->execution_time + $this->object->state->latency) / 1000 + 5; + $interval = $this->object->state->next_check->getTimestamp() + - $this->object->state->last_update->getTimestamp(); + + // In case passive check is used, the check_retry_interval has no effect. + // Since there is no flag in the database to check if the passive check was triggered. + // We have to manually check if the check_retry_interval matches the calculated interval. + if ( + $this->object->check_retry_interval - $delay <= $interval + && $this->object->check_retry_interval + $delay >= $interval + ) { + return $this->object->check_retry_interval; + } + + return $this->object->check_interval; + } + + protected function assemble() + { + parent::assemble(); + + if ($this->isChecksDisabled()) { + $this->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => 'checks-disabled-overlay']), + new HtmlElement( + 'strong', + Attributes::create(['class' => 'notes']), + Text::create(t('active and passive checks are disabled')) + ) + )); + } + } +} diff --git a/library/Icingadb/Widget/Detail/CommentDetail.php b/library/Icingadb/Widget/Detail/CommentDetail.php new file mode 100644 index 0000000..5b0923e --- /dev/null +++ b/library/Icingadb/Widget/Detail/CommentDetail.php @@ -0,0 +1,140 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Date\DateFormatter; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\TicketLinks; +use Icinga\Module\Icingadb\Model\Comment; +use Icinga\Module\Icingadb\Widget\MarkdownText; +use Icinga\Module\Icingadb\Forms\Command\Object\DeleteCommentForm; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Widget\HorizontalKeyValue; +use ipl\Web\Widget\StateBall; +use ipl\Web\Widget\TimeUntil; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class CommentDetail extends BaseHtmlElement +{ + use Auth; + use TicketLinks; + + protected $comment; + + protected $defaultAttributes = ['class' => ['object-detail', 'comment-detail']]; + + protected $tag = 'div'; + + public function __construct(Comment $comment) + { + $this->comment = $comment; + } + + protected function createComment(): array + { + return [ + Html::tag('h2', t('Comment')), + new MarkdownText($this->createTicketLinks($this->comment->text)) + ]; + } + + protected function createDetails(): array + { + $details = []; + + if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') { + if ($this->comment->object_type === 'host') { + $details[] = new HorizontalKeyValue(t('Host'), [ + $this->comment->host->name, + ' ', + new StateBall($this->comment->host->state->getStateText()) + ]); + } else { + $details[] = new HorizontalKeyValue(t('Service'), Html::sprintf( + t('%s on %s', '<service> on <host>'), + [$this->comment->service->name, ' ', new StateBall($this->comment->service->state->getStateText())], + $this->comment->host->name + )); + } + + $details[] = new HorizontalKeyValue(t('Author'), $this->comment->author); + $details[] = new HorizontalKeyValue( + t('Acknowledgement'), + $this->comment->entry_type === 'ack' ? t('Yes') : t('No') + ); + $details[] = new HorizontalKeyValue( + t('Persistent'), + $this->comment->is_persistent ? t('Yes') : t('No') + ); + $details[] = new HorizontalKeyValue( + t('Created'), + DateFormatter::formatDateTime($this->comment->entry_time->getTimestamp()) + ); + $details[] = new HorizontalKeyValue(t('Expires'), $this->comment->expire_time !== null + ? DateFormatter::formatDateTime($this->comment->expire_time->getTimestamp()) + : t('Never')); + } else { + if ($this->comment->expire_time !== null) { + $details[] = Html::tag( + 'p', + Html::sprintf( + $this->comment->entry_type === 'ack' + ? t('This acknowledgement expires %s.', '..<time-until>') + : t('This comment expires %s.', '..<time-until>'), + new TimeUntil($this->comment->expire_time->getTimestamp()) + ) + ); + } + + if ($this->comment->is_sticky) { + $details[] = Html::tag('p', t('This acknowledgement is sticky.')); + } + } + + if (! empty($details)) { + array_unshift($details, Html::tag('h2', t('Details'))); + } + + return $details; + } + + protected function createRemoveCommentForm() + { + if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') { + return null; + } + + $action = Links::commentsDelete(); + $action->setFilter(Filter::equal('name', $this->comment->name)); + + return (new DeleteCommentForm()) + ->setObjects([$this->comment]) + ->populate(['redirect' => '__BACK__']) + ->setAction($action->getAbsoluteUrl()); + } + + protected function assemble() + { + $this->add($this->createComment()); + + $details = $this->createDetails(); + + if (! empty($details)) { + $this->add($details); + } + + if ( + $this->isGrantedOn( + 'icingadb/command/comment/delete', + $this->comment->{$this->comment->object_type} + ) + ) { + $this->add($this->createRemoveCommentForm()); + } + } +} diff --git a/library/Icingadb/Widget/Detail/CustomVarTable.php b/library/Icingadb/Widget/Detail/CustomVarTable.php new file mode 100644 index 0000000..9d6916b --- /dev/null +++ b/library/Icingadb/Widget/Detail/CustomVarTable.php @@ -0,0 +1,268 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Hook\CustomVarRendererHook; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Orm\Model; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Icon; +use Closure; + +class CustomVarTable extends BaseHtmlElement +{ + /** @var array The variables */ + protected $data; + + /** @var ?Model The object the variables are bound to */ + protected $object; + + /** @var Closure Callback to apply hooks */ + protected $hookApplier; + + /** @var array The groups as identified by hooks */ + protected $groups = []; + + /** @var string Header title */ + protected $headerTitle; + + /** @var int The nesting level */ + protected $level = 0; + + protected $tag = 'table'; + + /** @var HtmlElement The table body */ + protected $body; + + protected $defaultAttributes = [ + 'class' => ['custom-var-table', 'name-value-table'] + ]; + + /** + * Create a new CustomVarTable + * + * @param iterable $data + * @param ?Model $object + */ + public function __construct($data, Model $object = null) + { + $this->data = $data; + $this->object = $object; + $this->body = new HtmlElement('tbody'); + } + + /** + * Set the header to show + * + * @param string $title + * + * @return $this + */ + protected function setHeader(string $title): self + { + $this->headerTitle = $title; + + return $this; + } + + /** + * Add a new row to the body + * + * @param mixed $name + * @param mixed $value + * + * @return void + */ + protected function addRow($name, $value) + { + $this->body->addHtml(new HtmlElement( + 'tr', + Attributes::create(['class' => "level-{$this->level}"]), + new HtmlElement('th', null, Html::wantHtml($name)), + new HtmlElement('td', null, Html::wantHtml($value)) + )); + } + + /** + * Render a variable + * + * @param mixed $name + * @param mixed $value + * + * @return void + */ + protected function renderVar($name, $value) + { + if ($this->object !== null && $this->level === 0) { + list($name, $value, $group) = call_user_func($this->hookApplier, $name, $value); + if ($group !== null) { + $this->groups[$group][] = [$name, $value]; + return; + } + } + + $isArray = is_array($value); + switch (true) { + case $isArray && is_int(key($value)): + $this->renderArray($name, $value); + break; + case $isArray: + $this->renderObject($name, $value); + break; + default: + $this->renderScalar($name, $value); + } + } + + /** + * Render an array + * + * @param string $name + * @param array $array + * + * @return void + */ + protected function renderArray($name, array $array) + { + $numItems = count($array); + $name = (new HtmlDocument())->addHtml( + Html::wantHtml($name), + Text::create(' (Array)') + ); + + $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems)); + + ++$this->level; + + ksort($array); + foreach ($array as $key => $value) { + $this->renderVar("[$key]", $value); + } + + --$this->level; + } + + /** + * Render an object (associative array) + * + * @param mixed $name + * @param array $object + * + * @return void + */ + protected function renderObject($name, array $object) + { + $numItems = count($object); + $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems)); + + ++$this->level; + + ksort($object); + foreach ($object as $key => $value) { + $this->renderVar($key, $value); + } + + --$this->level; + } + + /** + * Render a scalar + * + * @param mixed $name + * @param mixed $value + * + * @return void + */ + protected function renderScalar($name, $value) + { + if ($value === '') { + $value = new EmptyState(t('empty string')); + } + + $this->addRow($name, $value); + } + + /** + * Render a group + * + * @param string $name + * @param iterable $entries + * + * @return void + */ + protected function renderGroup(string $name, $entries) + { + $table = new self($entries); + + $wrapper = $this->getWrapper(); + if ($wrapper === null) { + $wrapper = new HtmlDocument(); + $wrapper->addHtml($this); + $this->prependWrapper($wrapper); + } + + $wrapper->addHtml($table->setHeader($name)); + } + + protected function assemble() + { + if ($this->object !== null) { + $this->hookApplier = CustomVarRendererHook::prepareForObject($this->object); + } + + if ($this->headerTitle !== null) { + $this->getAttributes() + ->add('class', 'collapsible') + ->add('data-visible-height', 100) + ->add('data-toggle-element', 'thead') + ->add( + 'id', + preg_replace('/\s+/', '-', strtolower($this->headerTitle)) . '-customvars' + ); + + $this->addHtml(new HtmlElement('thead', null, new HtmlElement( + 'tr', + null, + new HtmlElement( + 'th', + Attributes::create(['colspan' => 2]), + new HtmlElement( + 'span', + null, + new Icon('angle-right'), + new Icon('angle-down') + ), + Text::create($this->headerTitle) + ) + ))); + } + + if (is_array($this->data)) { + ksort($this->data); + } + + foreach ($this->data as $name => $value) { + $this->renderVar($name, $value); + } + + $this->addHtml($this->body); + + // Hooks can return objects as replacement for keys, hence a generator is needed for group entries + $genGenerator = function ($entries) { + foreach ($entries as list($key, $value)) { + yield $key => $value; + } + }; + + foreach ($this->groups as $group => $entries) { + $this->renderGroup($group, $genGenerator($entries)); + } + } +} diff --git a/library/Icingadb/Widget/Detail/DowntimeCard.php b/library/Icingadb/Widget/Detail/DowntimeCard.php new file mode 100644 index 0000000..81f59da --- /dev/null +++ b/library/Icingadb/Widget/Detail/DowntimeCard.php @@ -0,0 +1,258 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Model\Downtime; +use ipl\Html\Attributes; +use ipl\Html\HtmlElement; +use ipl\Web\Compat\StyleWithNonce; +use ipl\Web\Widget\TimeAgo; +use ipl\Web\Widget\TimeUntil; +use ipl\Web\Widget\VerticalKeyValue; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class DowntimeCard extends BaseHtmlElement +{ + protected $downtime; + + protected $duration; + + protected $defaultAttributes = ['class' => 'progress-bar downtime-progress']; + + protected $tag = 'div'; + + protected $start; + + protected $end; + + public function __construct(Downtime $downtime) + { + $this->downtime = $downtime; + + $this->start = $this->downtime->scheduled_start_time->getTimestamp(); + $this->end = $this->downtime->scheduled_end_time->getTimestamp(); + + if ($this->downtime->end_time > $this->downtime->scheduled_end_time) { + $this->duration = $this->downtime->end_time->getTimestamp() - $this->start; + } else { + $this->duration = $this->end - $this->start; + } + } + + protected function assemble() + { + $styleElement = (new StyleWithNonce()) + ->setModule('icingadb'); + + $timeline = Html::tag('div', ['class' => 'downtime-timeline timeline']); + $hPadding = 10; + + $above = Html::tag('ul', ['class' => 'above']); + $below = Html::tag('ul', ['class' => 'below']); + + $markerStart = new HtmlElement('div', Attributes::create(['class' => ['marker' , 'left']])); + $markerEnd = new HtmlElement('div', Attributes::create(['class' => ['marker', 'right']])); + + $timelineProgress = null; + $flexProgress = null; + $markerFlexStart = null; + $markerFlexEnd = null; + + if ($this->end < time()) { + $endTime = new TimeAgo($this->end); + } else { + $endTime = new TimeUntil($this->end); + } + + if ($this->downtime->is_flexible && $this->downtime->is_in_effect) { + $this->addAttributes(['class' => 'flexible in-effect']); + + $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->downtime->start_time->getTimestamp()); + $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->downtime->end_time->getTimestamp()); + + $evade = false; + if ($flexEndLeft - $flexStartLeft < 2) { + $flexStartLeft -= 1; + $flexEndLeft += 1; + + if ($flexEndLeft > $hPadding + $this->calcRelativeLeft($this->end)) { + $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->end) - .5; + $flexStartLeft = $flexEndLeft - 2; + } + + if ($flexStartLeft < $hPadding + $this->calcRelativeLeft($this->start)) { + $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->start) + .5; + $flexEndLeft = $flexStartLeft + 2; + } + + $evade = true; + } + + $markerFlexStart = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']])); + $markerFlexEnd = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']])); + + $styleElement + ->addFor($markerFlexStart, ['left' => sprintf('%F%%', $flexStartLeft)]) + ->addFor($markerFlexEnd, ['left' => sprintf('%F%%', $flexEndLeft)]); + + $scheduledEndBubble = new HtmlElement( + 'li', + null, + new HtmlElement( + 'div', + Attributes::create(['class' => ['bubble', 'upwards']]), + new VerticalKeyValue(t('Scheduled End'), $endTime) + ) + ); + + $timelineProgress = new HtmlElement('div', Attributes::create([ + 'class' => ['progress', 'downtime-elapsed'], + 'data-animate-progress' => true, + 'data-start-time' => ((float) $this->downtime->start_time->format('U.u')), + 'data-end-time' => ((float) $this->downtime->end_time->format('U.u')) + ]), new HtmlElement( + 'div', + Attributes::create(['class' => 'bar']), + new HtmlElement('div', Attributes::create(['class' => 'now'])) + )); + + $styleElement->addFor($timelineProgress, [ + 'left' => sprintf('%F%%', $flexStartLeft), + 'width' => sprintf('%F%%', $flexEndLeft - $flexStartLeft) + ]); + + if (time() > $this->end) { + $styleElement + ->addFor($markerEnd, [ + 'left' => sprintf('%F%%', $hPadding + $this->calcRelativeLeft($this->end)) + ]) + ->addFor($scheduledEndBubble, [ + 'left' => sprintf('%F%%', $hPadding + $this->calcRelativeLeft($this->end)) + ]); + } else { + $scheduledEndBubble->getAttributes() + ->add('class', 'right'); + } + + $below->add([ + Html::tag( + 'li', + ['class' => 'left'], + Html::tag( + 'div', + ['class' => ['bubble', 'upwards']], + new VerticalKeyValue(t('Scheduled Start'), new TimeAgo($this->start)) + ) + ), + $scheduledEndBubble + ]); + + $aboveStart = Html::tag('li', ['class' => 'positioned'], Html::tag( + 'div', + ['class' => ['bubble', ($evade ? 'left-aligned' : null)]], + new VerticalKeyValue(t('Start'), new TimeAgo($this->downtime->start_time->getTimestamp())) + )); + + $aboveEnd = Html::tag('li', ['class' => 'positioned'], Html::tag( + 'div', + ['class' => ['bubble', ($evade ? 'right-aligned' : null)]], + new VerticalKeyValue(t('End'), new TimeUntil($this->downtime->end_time->getTimestamp())) + )); + + $styleElement + ->addFor($aboveStart, ['left' => sprintf('%F%%', $flexStartLeft)]) + ->addFor($aboveEnd, ['left' => sprintf('%F%%', $flexEndLeft)]); + + $above->add([$aboveStart, $aboveEnd, $styleElement]); + } elseif ($this->downtime->is_flexible) { + $this->addAttributes(['class' => 'flexible']); + + $below->add([ + Html::tag( + 'li', + ['class' => 'left'], + Html::tag( + 'div', + ['class' => ['bubble', 'upwards']], + new VerticalKeyValue( + t('Scheduled Start'), + time() > $this->start + ? new TimeAgo($this->start) + : new TimeUntil($this->start) + ) + ) + ), + Html::tag( + 'li', + ['class' => 'right'], + Html::tag( + 'div', + ['class' => ['bubble', 'upwards']], + new VerticalKeyValue(t('Scheduled End'), $endTime) + ) + ) + ]); + + $above = null; + } else { + if (time() >= $this->start) { + $timelineProgress = new HtmlElement('div', Attributes::create([ + 'class' => ['progress', 'downtime-elapsed'], + 'data-animate-progress' => true, + 'data-start-time' => $this->start, + 'data-end-time' => $this->end + ]), new HtmlElement( + 'div', + Attributes::create(['class' => 'bar']), + new HtmlElement('div', Attributes::create(['class' => 'now'])) + )); + } + + $below->add([ + Html::tag( + 'li', + ['class' => 'left'], + Html::tag( + 'div', + ['class' => 'bubble upwards'], + new VerticalKeyValue(t('Start'), new TimeAgo($this->start)) + ) + ), + Html::tag( + 'li', + ['class' => 'right'], + Html::tag( + 'div', + ['class' => 'bubble upwards'], + new VerticalKeyValue(t('End'), new TimeUntil($this->end)) + ) + ) + ]); + + $above = null; + } + + $timeline->add([ + $timelineProgress, + $flexProgress, + $markerStart, + $markerEnd, + $markerFlexStart, + $markerFlexEnd + ]); + + $this->add([ + $above, + $timeline, + $below + ]); + } + + protected function calcRelativeLeft($value) + { + return round(($value - $this->start) / $this->duration * 80, 2); + } +} diff --git a/library/Icingadb/Widget/Detail/DowntimeDetail.php b/library/Icingadb/Widget/Detail/DowntimeDetail.php new file mode 100644 index 0000000..9e50f7f --- /dev/null +++ b/library/Icingadb/Widget/Detail/DowntimeDetail.php @@ -0,0 +1,206 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Date\DateFormatter; +use Icinga\Date\DateFormatter as WebDateFormatter; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\HostLink; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Widget\MarkdownText; +use Icinga\Module\Icingadb\Common\ServiceLink; +use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm; +use Icinga\Module\Icingadb\Model\Downtime; +use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList; +use Icinga\Module\Icingadb\Widget\ShowMore; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\HorizontalKeyValue; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\TemplateString; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBall; + +class DowntimeDetail extends BaseHtmlElement +{ + use Auth; + use Database; + use HostLink; + use ServiceLink; + + /** @var BaseHtmlElement */ + protected $control; + + /** @var Downtime */ + protected $downtime; + + protected $defaultAttributes = ['class' => ['object-detail', 'downtime-detail']]; + + protected $tag = 'div'; + + public function __construct(Downtime $downtime) + { + $this->downtime = $downtime; + } + + protected function createCancelDowntimeForm() + { + $action = Links::downtimesDelete(); + $action->setFilter(Filter::equal('name', $this->downtime->name)); + + return (new DeleteDowntimeForm()) + ->setObjects([$this->downtime]) + ->populate(['redirect' => '__BACK__']) + ->setAction($action->getAbsoluteUrl()); + } + + protected function createTimeline(): DowntimeCard + { + return new DowntimeCard($this->downtime); + } + + protected function assemble() + { + $this->add(Html::tag('h2', t('Comment'))); + $this->add(Html::tag('div', [ + new Icon('user'), + Html::sprintf( + t('%s commented: %s', '<username> ..: <comment>'), + $this->downtime->author, + new MarkdownText($this->downtime->comment) + ) + ])); + + $this->add(Html::tag('h2', t('Details'))); + + if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') { + $this->addHtml(new HorizontalKeyValue( + t('Type'), + $this->downtime->is_flexible ? t('Flexible') : t('Fixed') + )); + if ($this->downtime->object_type === 'host') { + $this->addHtml(new HorizontalKeyValue(t('Host'), [ + $this->downtime->host->name, + ' ', + new StateBall($this->downtime->host->state->getStateText()) + ])); + } else { + $this->addHtml(new HorizontalKeyValue(t('Service'), Html::sprintf( + t('%s on %s', '<service> on <host>'), + [ + $this->downtime->service->name, + ' ', + new StateBall($this->downtime->service->state->getStateText()) + ], + $this->downtime->host->name + ))); + } + } + + if ($this->downtime->triggered_by_id !== null || $this->downtime->parent_id !== null) { + if ($this->downtime->triggered_by_id !== null) { + $label = t('Triggered By'); + $relatedDowntime = $this->downtime->triggered_by; + } else { + $label = t('Parent'); + $relatedDowntime = $this->downtime->parent; + } + + $this->addHtml(new HorizontalKeyValue( + $label, + HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create( + $relatedDowntime->is_flexible + ? t('{{#link}}Flexible Downtime{{/link}} for %s') + : t('{{#link}}Fixed Downtime{{/link}} for %s'), + ['link' => new Link(null, Links::downtime($relatedDowntime), ['class' => 'subject'])], + ($relatedDowntime->object_type === 'host' + ? $this->createHostLink($relatedDowntime->host, true) + : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true)) + )) + )); + } + + $this->add(new HorizontalKeyValue( + t('Created'), + WebDateFormatter::formatDateTime($this->downtime->entry_time->getTimestamp()) + )); + $this->add(new HorizontalKeyValue( + t('Start time'), + $this->downtime->start_time + ? WebDateFormatter::formatDateTime($this->downtime->start_time->getTimestamp()) + : new EmptyState(t('Not started yet')) + )); + $this->add(new HorizontalKeyValue( + t('End time'), + $this->downtime->end_time + ? WebDateFormatter::formatDateTime($this->downtime->end_time->getTimestamp()) + : new EmptyState(t('Not started yet')) + )); + $this->add(new HorizontalKeyValue( + t('Scheduled Start'), + WebDateFormatter::formatDateTime($this->downtime->scheduled_start_time->getTimestamp()) + )); + $this->add(new HorizontalKeyValue( + t('Scheduled End'), + WebDateFormatter::formatDateTime($this->downtime->scheduled_end_time->getTimestamp()) + )); + $this->add(new HorizontalKeyValue( + t('Scheduled Duration'), + DateFormatter::formatDuration($this->downtime->scheduled_duration / 1000) + )); + if ($this->downtime->is_flexible) { + $this->add(new HorizontalKeyValue( + t('Flexible Duration'), + DateFormatter::formatDuration($this->downtime->flexible_duration / 1000) + )); + } + + $query = Downtime::on($this->getDb())->with([ + 'host', + 'host.state', + 'service', + 'service.host', + 'service.host.state', + 'service.state' + ]) + ->limit(3) + ->filter(Filter::equal('parent_id', $this->downtime->id)) + ->orFilter(Filter::equal('triggered_by_id', $this->downtime->id)); + $this->applyRestrictions($query); + + $children = $query->peekAhead()->execute(); + if ($children->hasResult()) { + $this->addHtml( + new HtmlElement('h2', null, Text::create(t('Children'))), + new DowntimeList($children), + (new ShowMore($children, Links::downtimes()->setQueryString( + QueryString::render(Filter::any( + Filter::equal('downtime.parent.name', $this->downtime->name), + Filter::equal('downtime.triggered_by.name', $this->downtime->name) + )) + )))->setBaseTarget('_next') + ); + } + + $this->add(Html::tag('h2', t('Progress'))); + $this->add($this->createTimeline()); + + if ( + getenv('ICINGAWEB_EXPORT_FORMAT') !== 'pdf' + && $this->isGrantedOn( + 'icingadb/command/downtime/delete', + $this->downtime->{$this->downtime->object_type} + ) + ) { + $this->add($this->createCancelDowntimeForm()); + } + } +} diff --git a/library/Icingadb/Widget/Detail/EventDetail.php b/library/Icingadb/Widget/Detail/EventDetail.php new file mode 100644 index 0000000..181c9ae --- /dev/null +++ b/library/Icingadb/Widget/Detail/EventDetail.php @@ -0,0 +1,651 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use DateTime; +use DateTimeZone; +use Icinga\Date\DateFormatter; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\HostLink; +use Icinga\Module\Icingadb\Common\HostStates; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\TicketLinks; +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Widget\MarkdownText; +use Icinga\Module\Icingadb\Common\ServiceLink; +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Module\Icingadb\Model\AcknowledgementHistory; +use Icinga\Module\Icingadb\Model\CommentHistory; +use Icinga\Module\Icingadb\Model\DowntimeHistory; +use Icinga\Module\Icingadb\Model\FlappingHistory; +use Icinga\Module\Icingadb\Model\History; +use Icinga\Module\Icingadb\Model\NotificationHistory; +use Icinga\Module\Icingadb\Model\StateHistory; +use Icinga\Module\Icingadb\Util\PluginOutput; +use Icinga\Module\Icingadb\Widget\ShowMore; +use ipl\Web\Widget\CopyToClipboard; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\HorizontalKeyValue; +use Icinga\Module\Icingadb\Widget\ItemTable\UserTable; +use Icinga\Module\Icingadb\Widget\PluginOutputContainer; +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormattedString; +use ipl\Html\HtmlElement; +use ipl\Html\TemplateString; +use ipl\Html\Text; +use ipl\Orm\ResultSet; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Str; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBall; + +class EventDetail extends BaseHtmlElement +{ + use Auth; + use Database; + use HostLink; + use ServiceLink; + use TicketLinks; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'object-detail']; + + /** @var History */ + protected $event; + + public function __construct(History $event) + { + $this->event = $event; + } + + protected function assembleNotificationEvent(NotificationHistory $notification) + { + $pluginOutput = []; + + $commandName = $notification->object_type === 'host' + ? $this->event->host->checkcommand_name + : $this->event->service->checkcommand_name; + if (isset($commandName)) { + if (empty($notification->text)) { + $notificationText = new EmptyState(t('Output unavailable.')); + } else { + $notificationText = new PluginOutputContainer( + (new PluginOutput($notification->text)) + ->setCommandName($notification->object_type === 'host' + ? $this->event->host->checkcommand_name + : $this->event->service->checkcommand_name) + ); + + CopyToClipboard::attachTo($notificationText); + } + + $pluginOutput = [ + HtmlElement::create('h2', null, $notification->author ? t('Comment') : t('Plugin Output')), + HtmlElement::create('div', [ + 'id' => 'check-output-' . $commandName, + 'class' => 'collapsible', + 'data-visible-height' => 100 + ], $notificationText) + ]; + } else { + $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.')); + } + + if ($notification->object_type === 'host') { + $objectKey = t('Host'); + $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [ + HtmlElement::create('span', ['class' => 'state-change'], [ + new StateBall(HostStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM), + new StateBall(HostStates::text($notification->state), StateBall::SIZE_MEDIUM) + ]), + ' ', + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ]); + } else { + $objectKey = t('Service'); + $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [ + HtmlElement::create('span', ['class' => 'state-change'], [ + new StateBall(ServiceStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM), + new StateBall(ServiceStates::text($notification->state), StateBall::SIZE_MEDIUM) + ]), + ' ', + FormattedString::create( + t('%s on %s', '<service> on <host>'), + HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name), + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ) + ]); + } + + $eventInfo = [ + new HtmlElement('h2', null, Text::create(t('Event Info'))), + new HorizontalKeyValue( + t('Sent On'), + DateFormatter::formatDateTime($notification->send_time->getTimestamp()) + ) + ]; + + if ($notification->author) { + $eventInfo[] = (new HorizontalKeyValue(t('Sent by'), [ + new Icon('user'), + $notification->author + ])); + } + + $eventInfo[] = new HorizontalKeyValue(t('Type'), ucfirst(Str::camel($notification->type))); + $eventInfo[] = new HorizontalKeyValue(t('State'), $notification->object_type === 'host' + ? ucfirst(HostStates::text($notification->state)) + : ucfirst(ServiceStates::text($notification->state))); + $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo); + + + $notifiedUsers = [new HtmlElement('h2', null, Text::create(t('Notified Users')))]; + + if ($notification->users_notified === 0) { + $notifiedUsers[] = new EmptyState(t('None', 'notified users: none')); + } elseif (! $this->isPermittedRoute('users')) { + $notifiedUsers[] = Text::create(sprintf(tp( + 'This notification was sent to a single user', + 'This notification was sent to %d users', + $notification->users_notified + ), $notification->users_notified)); + } elseif ($notification->users_notified > 0) { + $users = $notification->user + ->limit(5) + ->peekAhead(); + + $users = $users->execute(); + /** @var ResultSet $users */ + + $notifiedUsers[] = new UserTable($users); + $notifiedUsers[] = (new ShowMore( + $users, + Links::users()->addParams(['notification_history.id' => bin2hex($notification->id)]), + sprintf(t('Show all %d recipients'), $notification->users_notified) + ))->setBaseTarget('_next'); + } + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 0 => $pluginOutput, + 200 => $eventInfo, + 500 => $notifiedUsers + ], $this->createExtensions())); + } + + protected function assembleStateChangeEvent(StateHistory $stateChange) + { + $pluginOutput = []; + + $commandName = $stateChange->object_type === 'host' + ? $this->event->host->checkcommand_name + : $this->event->service->checkcommand_name; + if (isset($commandName)) { + if (empty($stateChange->output) && empty($stateChange->long_output)) { + $commandOutput = new EmptyState(t('Output unavailable.')); + } else { + $commandOutput = new PluginOutputContainer( + (new PluginOutput($stateChange->output . "\n" . $stateChange->long_output)) + ->setCommandName($commandName) + ); + + CopyToClipboard::attachTo($commandOutput); + } + + $pluginOutput = [ + new HtmlElement('h2', null, Text::create(t('Plugin Output'))), + HtmlElement::create('div', [ + 'id' => 'check-output-' . $commandName, + 'class' => 'collapsible', + 'data-visible-height' => 100 + ], $commandOutput) + ]; + } else { + $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.')); + } + + if ($stateChange->object_type === 'host') { + $objectKey = t('Host'); + $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state; + $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [ + HtmlElement::create('span', ['class' => 'state-change'], [ + new StateBall(HostStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM), + new StateBall(HostStates::text($objectState), StateBall::SIZE_MEDIUM) + ]), + ' ', + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ]); + } else { + $objectKey = t('Service'); + $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state; + $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [ + HtmlElement::create('span', ['class' => 'state-change'], [ + new StateBall(ServiceStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM), + new StateBall(ServiceStates::text($objectState), StateBall::SIZE_MEDIUM) + ]), + ' ', + FormattedString::create( + t('%s on %s', '<service> on <host>'), + HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name), + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ) + ]); + } + + $eventInfo = [ + new HtmlElement('h2', null, Text::create(t('Event Info'))), + new HorizontalKeyValue( + t('Occurred On'), + DateFormatter::formatDateTime($stateChange->event_time->getTimestamp()) + ), + new HorizontalKeyValue(t('Scheduling Source'), $stateChange->scheduling_source), + new HorizontalKeyValue(t('Check Source'), $stateChange->check_source) + ]; + + if ($stateChange->state_type === 'soft') { + $eventInfo[] = new HorizontalKeyValue(t('Check Attempt'), sprintf( + t('%d of %d'), + $stateChange->check_attempt, + $stateChange->max_check_attempts + )); + } + + $eventInfo[] = new HorizontalKeyValue( + t('State'), + $stateChange->object_type === 'host' + ? ucfirst(HostStates::text($objectState)) + : ucfirst(ServiceStates::text($objectState)) + ); + + $eventInfo[] = new HorizontalKeyValue( + t('State Type'), + $stateChange->state_type === 'hard' ? t('Hard', 'state') : t('Soft', 'state') + ); + + $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo); + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 0 => $pluginOutput, + 200 => $eventInfo + ], $this->createExtensions())); + } + + protected function assembleDowntimeEvent(DowntimeHistory $downtime) + { + $commentInfo = [ + new HtmlElement('h2', null, Text::create(t('Comment'))), + new MarkdownText($this->createTicketLinks($downtime->comment)) + ]; + + $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))]; + + if ($downtime->triggered_by_id !== null || $downtime->parent_id !== null) { + if ($downtime->triggered_by_id !== null) { + $label = t('Triggered By'); + $relatedDowntime = $downtime->triggered_by; + } else { + $label = t('Parent'); + $relatedDowntime = $downtime->parent; + } + + $query = History::on($this->getDb()) + ->columns('id') + ->filter(Filter::equal('event_type', 'downtime_start')) + ->filter(Filter::equal('history.downtime_history_id', $relatedDowntime->downtime_id)); + $this->applyRestrictions($query); + if (($relatedEvent = $query->first()) !== null) { + /** @var History $relatedEvent */ + $eventInfo[] = new HorizontalKeyValue( + $label, + HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create( + $relatedDowntime->is_flexible + ? t('{{#link}}Flexible Downtime{{/link}} for %s') + : t('{{#link}}Fixed Downtime{{/link}} for %s'), + ['link' => new Link(null, Links::event($relatedEvent), ['class' => 'subject'])], + ($relatedDowntime->object_type === 'host' + ? $this->createHostLink($relatedDowntime->host, true) + : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true)) + ->addAttributes(['class' => 'subject']) + )) + ); + } + } + + $eventInfo[] = $downtime->object_type === 'host' + ? new HorizontalKeyValue(t('Host'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + )) + : new HorizontalKeyValue(t('Service'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + FormattedString::create( + t('%s on %s', '<service> on <host>'), + HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name), + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ) + )); + $eventInfo[] = new HorizontalKeyValue( + t('Entered On'), + DateFormatter::formatDateTime($downtime->entry_time->getTimestamp()) + ); + $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $downtime->author]); + // TODO: The following should be presented in a specific widget (maybe just like the downtime card) + $eventInfo[] = new HorizontalKeyValue( + t('Triggered On'), + DateFormatter::formatDateTime($downtime->trigger_time->getTimestamp()) + ); + $eventInfo[] = new HorizontalKeyValue( + t('Scheduled Start'), + DateFormatter::formatDateTime($downtime->scheduled_start_time->getTimestamp()) + ); + $eventInfo[] = new HorizontalKeyValue( + t('Actual Start'), + DateFormatter::formatDateTime($downtime->start_time->getTimestamp()) + ); + $eventInfo[] = new HorizontalKeyValue( + t('Scheduled End'), + DateFormatter::formatDateTime($downtime->scheduled_end_time->getTimestamp()) + ); + $eventInfo[] = new HorizontalKeyValue( + t('Actual End'), + DateFormatter::formatDateTime($downtime->end_time->getTimestamp()) + ); + + if ($downtime->is_flexible) { + $eventInfo[] = new HorizontalKeyValue(t('Flexible'), t('Yes')); + $eventInfo[] = new HorizontalKeyValue( + t('Duration'), + DateFormatter::formatDuration($downtime->flexible_duration / 1000) + ); + } + + $cancelInfo = []; + if ($downtime->has_been_cancelled) { + $cancelInfo = [ + new HtmlElement('h2', null, Text::create(t('This downtime has been cancelled'))), + new HorizontalKeyValue( + t('Cancelled On'), + DateFormatter::formatDateTime($downtime->cancel_time->getTimestamp()) + ), + new HorizontalKeyValue(t('Cancelled by'), [new Icon('user'), $downtime->cancelled_by]) + ]; + } + + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 200 => $commentInfo, + 201 => $eventInfo, + 600 => $cancelInfo + ], $this->createExtensions())); + } + + protected function assembleCommentEvent(CommentHistory $comment) + { + $commentInfo = [ + new HtmlElement('h2', null, Text::create(t('Comment'))), + new MarkdownText($this->createTicketLinks($comment->comment)) + ]; + + $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))]; + $eventInfo[] = $comment->object_type === 'host' + ? new HorizontalKeyValue(t('Host'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + )) + : new HorizontalKeyValue(t('Service'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + FormattedString::create( + t('%s on %s', '<service> on <host>'), + HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name), + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ) + )); + $eventInfo[] = new HorizontalKeyValue( + t('Entered On'), + DateFormatter::formatDateTime($comment->entry_time->getTimestamp()) + ); + $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $comment->author]); + $eventInfo[] = new HorizontalKeyValue( + t('Expires On'), + $comment->expire_time + ? DateFormatter::formatDateTime($comment->expire_time->getTimestamp()) + : new EmptyState(t('Never')) + ); + + $tiedToAckInfo = []; + if ($comment->entry_type === 'ack') { + $tiedToAckInfo = [ + new HtmlElement('h2', null, Text::create(t('This comment is tied to an acknowledgement'))), + new HorizontalKeyValue(t('Sticky'), $comment->is_sticky ? t('Yes') : t('No')), + new HorizontalKeyValue(t('Persistent'), $comment->is_persistent ? t('Yes') : t('No')) + ]; + } + + $removedInfo = []; + if ($comment->has_been_removed) { + $removedInfo[] = new HtmlElement('h2', null, Text::create(t('This comment has been removed'))); + if ($comment->removed_by) { + $removedInfo[] = new HorizontalKeyValue( + t('Removed On'), + DateFormatter::formatDateTime($comment->remove_time->getTimestamp()) + ); + $removedInfo[] = new HorizontalKeyValue( + t('Removed by'), + [new Icon('user'), $comment->removed_by] + ); + } else { + $removedInfo[] = new HorizontalKeyValue( + t('Expired On'), + DateFormatter::formatDateTime($comment->remove_time->getTimestamp()) + ); + } + } + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 200 => $commentInfo, + 201 => $eventInfo, + 500 => $tiedToAckInfo, + 600 => $removedInfo + ], $this->createExtensions())); + } + + protected function assembleFlappingEvent(FlappingHistory $flapping) + { + $eventInfo = [ + new HtmlElement('h2', null, Text::create(t('Event Info'))), + $flapping->object_type === 'host' + ? new HorizontalKeyValue(t('Host'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + )) + : new HorizontalKeyValue(t('Service'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + FormattedString::create( + t('%s on %s', '<service> on <host>'), + HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name), + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ) + )), + new HorizontalKeyValue( + t('Started on'), + DateFormatter::formatDateTime($flapping->start_time->getTimestamp()) + ) + ]; + if ($this->event->event_type === 'flapping_start') { + $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf( + t('State change rate of %.2f%% exceeded the threshold (%.2f%%)'), + $flapping->percent_state_change_start, + $flapping->flapping_threshold_high + )); + } else { + $eventInfo[] = new HorizontalKeyValue( + t('Ended on'), + DateFormatter::formatDateTime($flapping->end_time->getTimestamp()) + ); + $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf( + t('State change rate of %.2f%% undercut the threshold (%.2f%%)'), + $flapping->percent_state_change_end, + $flapping->flapping_threshold_low + )); + } + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 200 => $eventInfo + ], $this->createExtensions())); + } + + protected function assembleAcknowledgeEvent(AcknowledgementHistory $acknowledgement) + { + $commentInfo = []; + if ($acknowledgement->comment) { + $commentInfo = [ + new HtmlElement('h2', null, Text::create(t('Comment'))), + new MarkdownText($this->createTicketLinks($acknowledgement->comment)) + ]; + } elseif (! isset($acknowledgement->author)) { + $commentInfo[] = new EmptyState(t('This acknowledgement was set before Icinga DB history recording')); + } + + $eventInfo = [ + new HtmlElement('h2', null, Text::create(t('Event Info'))), + new HorizontalKeyValue( + t('Set on'), + DateFormatter::formatDateTime($acknowledgement->set_time->getTimestamp()) + ), + new HorizontalKeyValue(t('Author'), $acknowledgement->author + ? [new Icon('user'), $acknowledgement->author] + : new EmptyState(t('n. a.'))), + $acknowledgement->object_type === 'host' + ? new HorizontalKeyValue(t('Host'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + )) + : new HorizontalKeyValue(t('Service'), HtmlElement::create( + 'span', + ['class' => 'accompanying-text'], + FormattedString::create( + t('%s on %s', '<service> on <host>'), + HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name), + HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name) + ) + )) + ]; + + if ($this->event->event_type === 'ack_set') { + $eventInfo[] = new HorizontalKeyValue( + t('Expires On'), + $acknowledgement->expire_time + ? DateFormatter::formatDateTime($acknowledgement->expire_time->getTimestamp()) + : new EmptyState(t('Never')) + ); + $eventInfo[] = new HorizontalKeyValue(t('Sticky'), isset($acknowledgement->is_sticky) + ? ($acknowledgement->is_sticky ? t('Yes') : t('No')) + : new EmptyState(t('n. a.'))); + $eventInfo[] = new HorizontalKeyValue(t('Persistent'), isset($acknowledgement->is_persistent) + ? ($acknowledgement->is_persistent ? t('Yes') : t('No')) + : new EmptyState(t('n. a.'))); + } else { + $eventInfo[] = new HorizontalKeyValue( + t('Cleared on'), + DateFormatter::formatDateTime( + $acknowledgement->clear_time + ? $acknowledgement->clear_time->getTimestamp() + : $this->event->event_time->getTimestamp() + ) + ); + if ($acknowledgement->cleared_by) { + $eventInfo[] = new HorizontalKeyValue( + t('Cleared by'), + [new Icon('user', $acknowledgement->cleared_by)] + ); + } else { + $expired = false; + if ($acknowledgement->expire_time) { + $now = (new DateTime())->setTimezone(new DateTimeZone('UTC')); + $expiresOn = clone $now; + $expiresOn->setTimestamp($acknowledgement->expire_time->getTimestamp()); + if ($now <= $expiresOn) { + $expired = true; + $eventInfo[] = new HorizontalKeyValue(t('Removal Reason'), t( + 'The acknowledgement expired on %s', + DateFormatter::formatDateTime($acknowledgement->expire_time->getTimestamp()) + )); + } + } + + if (! $expired) { + if ($acknowledgement->is_sticky) { + $eventInfo[] = new HorizontalKeyValue( + t('Reason'), + $acknowledgement->object_type === 'host' + ? t('Host recovered') + : t('Service recovered') + ); + } else { + $eventInfo[] = new HorizontalKeyValue( + t('Reason'), + $acknowledgement->object_type === 'host' + ? t('Host recovered') // Hosts have no other state between UP and DOWN + : t('Service changed its state') + ); + } + } + } + } + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 200 => $commentInfo, + 201 => $eventInfo + ], $this->createExtensions())); + } + + protected function createExtensions(): array + { + return ObjectDetailExtensionHook::loadExtensions($this->event); + } + + protected function assemble() + { + switch ($this->event->event_type) { + case 'notification': + $this->assembleNotificationEvent($this->event->notification); + + break; + case 'state_change': + $this->assembleStateChangeEvent($this->event->state); + + break; + case 'downtime_start': + case 'downtime_end': + $this->assembleDowntimeEvent($this->event->downtime); + + break; + case 'comment_add': + case 'comment_remove': + $this->assembleCommentEvent($this->event->comment); + + break; + case 'flapping_start': + case 'flapping_end': + $this->assembleFlappingEvent($this->event->flapping); + + break; + case 'ack_set': + case 'ack_clear': + $this->assembleAcknowledgeEvent($this->event->acknowledgement); + + break; + } + } +} diff --git a/library/Icingadb/Widget/Detail/HostDetail.php b/library/Icingadb/Widget/Detail/HostDetail.php new file mode 100644 index 0000000..8b80480 --- /dev/null +++ b/library/Icingadb/Widget/Detail/HostDetail.php @@ -0,0 +1,58 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\ServicestateSummary; +use ipl\Html\Html; +use ipl\Stdlib\Filter; +use ipl\Web\Widget\EmptyState; + +class HostDetail extends ObjectDetail +{ + protected $serviceSummary; + + public function __construct(Host $object, ServicestateSummary $serviceSummary) + { + parent::__construct($object); + + $this->serviceSummary = $serviceSummary; + } + + protected function createServiceStatistics(): array + { + if ($this->serviceSummary->services_total > 0) { + $services = new ServiceStatistics($this->serviceSummary); + $services->setBaseFilter(Filter::equal('host.name', $this->object->name)); + } else { + $services = new EmptyState(t('This host has no services')); + } + + return [Html::tag('h2', t('Services')), $services]; + } + + protected function assemble() + { + if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') { + $this->add($this->createPrintHeader()); + } + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 0 => $this->createPluginOutput(), + 190 => $this->createServiceStatistics(), + 300 => $this->createActions(), + 301 => $this->createNotes(), + 400 => $this->createComments(), + 401 => $this->createDowntimes(), + 500 => $this->createGroups(), + 501 => $this->createNotifications(), + 600 => $this->createCheckStatistics(), + 601 => $this->createPerformanceData(), + 700 => $this->createCustomVars(), + 701 => $this->createFeatureToggles() + ], $this->createExtensions())); + } +} diff --git a/library/Icingadb/Widget/Detail/HostInspectionDetail.php b/library/Icingadb/Widget/Detail/HostInspectionDetail.php new file mode 100644 index 0000000..93b35b8 --- /dev/null +++ b/library/Icingadb/Widget/Detail/HostInspectionDetail.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Common\ObjectInspectionDetail; + +class HostInspectionDetail extends ObjectInspectionDetail +{ + protected function assemble() + { + $this->add([ + $this->createSourceLocation(), + $this->createLastCheckResult(), + $this->createAttributes(), + $this->createCustomVariables(), + $this->createRedisInfo() + ]); + } +} diff --git a/library/Icingadb/Widget/Detail/HostMetaInfo.php b/library/Icingadb/Widget/Detail/HostMetaInfo.php new file mode 100644 index 0000000..78209aa --- /dev/null +++ b/library/Icingadb/Widget/Detail/HostMetaInfo.php @@ -0,0 +1,77 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Date\DateFormatter; +use Icinga\Module\Icingadb\Model\Host; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\HorizontalKeyValue; +use ipl\Web\Widget\VerticalKeyValue; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Web\Widget\Icon; + +class HostMetaInfo extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'object-meta-info']; + + /** @var Host */ + protected $host; + + public function __construct(Host $host) + { + $this->host = $host; + } + + protected function assemble() + { + $this->addHtml( + new VerticalKeyValue('host.name', $this->host->name), + new HtmlElement( + 'div', + null, + new HorizontalKeyValue( + 'host.address', + $this->host->address ?: new EmptyState(t('None', 'address')) + ), + new HorizontalKeyValue( + 'host.address6', + $this->host->address6 ?: new EmptyState(t('None', 'address')) + ) + ), + new VerticalKeyValue( + 'last_state_change', + $this->host->state->last_state_change !== null + ? DateFormatter::formatDateTime($this->host->state->last_state_change->getTimestamp()) + : (new EmptyState(t('n. a.')))->setTag('span') + ) + ); + + $collapsible = new HtmlElement('div', Attributes::create([ + 'class' => 'collapsible', + 'id' => 'object-meta-info', + 'data-toggle-element' => '.object-meta-info-control', + 'data-visible-height' => 0 + ])); + + $renderHelper = new HtmlDocument(); + $renderHelper->addHtml( + $this, + new HtmlElement( + 'button', + Attributes::create(['class' => 'object-meta-info-control']), + new Icon('angle-double-up', ['class' => 'collapse-icon']), + new Icon('angle-double-down', ['class' => 'expand-icon']) + ) + ); + + $this->addWrapper($collapsible); + $this->addWrapper($renderHelper); + } +} diff --git a/library/Icingadb/Widget/Detail/HostStatistics.php b/library/Icingadb/Widget/Detail/HostStatistics.php new file mode 100644 index 0000000..bcfc3f8 --- /dev/null +++ b/library/Icingadb/Widget/Detail/HostStatistics.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Chart\Donut; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Widget\HostStateBadges; +use ipl\Html\ValidHtml; +use ipl\Web\Widget\VerticalKeyValue; +use ipl\Html\HtmlString; +use ipl\Web\Filter\QueryString; +use ipl\Web\Widget\Link; + +class HostStatistics extends ObjectStatistics +{ + protected $summary; + + public function __construct($summary) + { + $this->summary = $summary; + } + + protected function createDonut(): ValidHtml + { + $donut = (new Donut()) + ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok']) + ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled']) + ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical']) + ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']); + + return HtmlString::create($donut->render()); + } + + protected function createTotal(): ValidHtml + { + $url = Links::hosts(); + if ($this->hasBaseFilter()) { + $url->setFilter($this->getBaseFilter()); + } + + return new Link( + (new VerticalKeyValue( + tp('Host', 'Hosts', $this->summary->hosts_total), + $this->shortenAmount($this->summary->hosts_total) + ))->setAttribute('title', $this->summary->hosts_total), + $url + ); + } + + protected function createBadges(): ValidHtml + { + $badges = new HostStateBadges($this->summary); + if ($this->hasBaseFilter()) { + $badges->setBaseFilter($this->getBaseFilter()); + } + + return $badges; + } +} diff --git a/library/Icingadb/Widget/Detail/MultiselectQuickActions.php b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php new file mode 100644 index 0000000..f398d80 --- /dev/null +++ b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php @@ -0,0 +1,194 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm; +use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\BaseFilter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class MultiselectQuickActions extends BaseHtmlElement +{ + use BaseFilter; + use Auth; + + protected $summary; + + protected $type; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'quick-actions']; + + public function __construct($type, $summary) + { + $this->summary = $summary; + $this->type = $type; + } + + protected function assemble() + { + $unacknowledged = "{$this->type}s_problems_unacknowledged"; + $acks = "{$this->type}s_acknowledged"; + $activeChecks = "{$this->type}s_active_checks_enabled"; + + if ( + $this->summary->$unacknowledged > $this->summary->$acks + && $this->isGrantedOnType( + 'icingadb/command/acknowledge-problem', + $this->type, + $this->getBaseFilter(), + false + ) + ) { + $this->assembleAction( + 'acknowledge', + t('Acknowledge'), + 'check-circle', + t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled') + ); + } + + if ( + $this->summary->$acks > 0 + && $this->isGrantedOnType( + 'icingadb/command/remove-acknowledgement', + $this->type, + $this->getBaseFilter(), + false + ) + ) { + $removeAckForm = (new RemoveAcknowledgementForm()) + ->setAction($this->getLink('removeAcknowledgement')) + ->setObjects(array_fill(0, $this->summary->$acks, null)); + + $this->add(Html::tag('li', $removeAckForm)); + } + + if ( + $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false) + || ( + $this->summary->$activeChecks > 0 + && $this->isGrantedOnType( + 'icingadb/command/schedule-check/active-only', + $this->type, + $this->getBaseFilter(), + false + ) + ) + ) { + $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow')))); + } + + if ($this->isGrantedOnType('icingadb/command/comment/add', $this->type, $this->getBaseFilter(), false)) { + $this->assembleAction( + 'addComment', + t('Comment'), + 'comment', + t('Add a new comment') + ); + } + + if ( + $this->isGrantedOnType( + 'icingadb/command/send-custom-notification', + $this->type, + $this->getBaseFilter(), + false + ) + ) { + $this->assembleAction( + 'sendCustomNotification', + t('Notification'), + 'bell', + t('Send a custom notification') + ); + } + + if ( + $this->isGrantedOnType( + 'icingadb/command/downtime/schedule', + $this->type, + $this->getBaseFilter(), + false + ) + ) { + $this->assembleAction( + 'scheduleDowntime', + t('Downtime'), + 'plug', + t('Schedule a downtime to suppress all problem notifications within a specific period of time') + ); + } + + if ( + $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false) + || ( + $this->summary->$activeChecks > 0 + && $this->isGrantedOnType( + 'icingadb/command/schedule-check/active-only', + $this->type, + $this->getBaseFilter(), + false + ) + ) + ) { + $this->assembleAction( + 'scheduleCheck', + t('Reschedule'), + 'calendar', + t('Schedule the next active check at a different time than the current one') + ); + } + + if ( + $this->isGrantedOnType( + 'icingadb/command/process-check-result', + $this->type, + $this->getBaseFilter(), + false + ) + ) { + $this->assembleAction( + 'processCheckresult', + t('Process check result'), + 'edit', + t('Submit passive check result') + ); + } + } + + protected function assembleAction(string $action, string $label, string $icon, string $title) + { + $link = Html::tag( + 'a', + [ + 'href' => $this->getLink($action), + 'class' => 'action-link', + 'title' => $title, + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ], + [ + new Icon($icon), + $label + ] + ); + + $this->add(Html::tag('li', $link)); + } + + protected function getLink(string $action): string + { + return Url::fromPath("icingadb/{$this->type}s/$action") + ->setFilter($this->getBaseFilter()) + ->getAbsoluteUrl(); + } +} diff --git a/library/Icingadb/Widget/Detail/ObjectDetail.php b/library/Icingadb/Widget/Detail/ObjectDetail.php new file mode 100644 index 0000000..a688173 --- /dev/null +++ b/library/Icingadb/Widget/Detail/ObjectDetail.php @@ -0,0 +1,596 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Exception; +use Icinga\Application\ClassLoader; +use Icinga\Application\Hook; +use Icinga\Application\Hook\GrapherHook; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Application\Web; +use Icinga\Date\DateFormatter; +use Icinga\Exception\IcingaException; +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\HostLinks; +use Icinga\Module\Icingadb\Common\Icons; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\Macros; +use Icinga\Module\Icingadb\Compat\CompatHost; +use Icinga\Module\Icingadb\Compat\CompatService; +use Icinga\Module\Icingadb\Model\CustomvarFlat; +use Icinga\Module\Icingadb\Web\Navigation\Action; +use Icinga\Module\Icingadb\Widget\MarkdownText; +use Icinga\Module\Icingadb\Common\ServiceLinks; +use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm; +use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook; +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\User; +use Icinga\Module\Icingadb\Model\Usergroup; +use Icinga\Module\Icingadb\Util\PluginOutput; +use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList; +use Icinga\Module\Icingadb\Widget\ShowMore; +use ipl\Web\Widget\CopyToClipboard; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\HorizontalKeyValue; +use Icinga\Module\Icingadb\Widget\ItemList\CommentList; +use Icinga\Module\Icingadb\Widget\PluginOutputContainer; +use Icinga\Module\Icingadb\Widget\TagList; +use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook; +use Icinga\Web\Navigation\Navigation; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Orm\ResultSet; +use ipl\Stdlib\Filter; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; + +class ObjectDetail extends BaseHtmlElement +{ + use Auth; + use Database; + use Macros; + + protected $object; + + protected $compatObject; + + protected $objectType; + + protected $defaultAttributes = [ + // Class host-detail is kept as the grafana module's iframe.js depends on it + 'class' => ['object-detail', 'host-detail'], + 'data-pdfexport-page-breaks-at' => 'h2' + ]; + + protected $tag = 'div'; + + public function __construct($object) + { + $this->object = $object; + $this->objectType = $object instanceof Host ? 'host' : 'service'; + } + + protected function compatObject() + { + if ($this->compatObject === null) { + $this->compatObject = CompatHost::fromModel($this->object); + } + + return $this->compatObject; + } + + protected function createPrintHeader() + { + $info = [new HorizontalKeyValue(t('Name'), $this->object->name)]; + + if ($this->objectType === 'host') { + $info[] = new HorizontalKeyValue( + t('IPv4 Address'), + $this->object->address ?: new EmptyState(t('None', 'address')) + ); + $info[] = new HorizontalKeyValue( + t('IPv6 Address'), + $this->object->address6 ?: new EmptyState(t('None', 'address')) + ); + } + + $info[] = new HorizontalKeyValue(t('State'), [ + $this->object->state->getStateTextTranslated(), + ' ', + new StateBall($this->object->state->getStateText()) + ]); + + $info[] = new HorizontalKeyValue( + t('Last State Change'), + DateFormatter::formatDateTime($this->object->state->last_state_change->getTimestamp()) + ); + + return [ + new HtmlElement('h2', null, Text::create( + $this->objectType === 'host' ? t('Host') : t('Service') + )), + $info + ]; + } + + protected function createActions() + { + $this->fetchCustomVars(); + + $navigation = new Navigation(); + $navigation->load('icingadb-' . $this->objectType . '-action'); + /** @var Action $item */ + foreach ($navigation as $item) { + $item->setObject($this->object); + } + + $monitoringInstalled = Icinga::app()->getModuleManager()->hasInstalled('monitoring'); + $obj = $monitoringInstalled ? $this->compatObject() : $this->object; + foreach ($this->object->action_url->first()->action_url ?? [] as $url) { + $url = $this->expandMacros($url, $obj); + $navigation->addItem( + Html::wantHtml([ + // Add warning to links that open in new tabs, as recommended by WCAG20 G201 + new Icon('external-link-alt', ['title' => t('Link opens in a new window')]), + $url + ])->render(), + [ + 'target' => '_blank', + 'url' => $url, + 'renderer' => [ + 'NavigationItemRenderer', + 'escape_label' => false + ] + ] + ); + } + + $moduleActions = ObjectActionsHook::loadActions($this->object); + + $nativeExtensionProviders = []; + foreach ($moduleActions->getContent() as $item) { + if ($item->getAttributes()->has('data-icinga-module')) { + $nativeExtensionProviders[$item->getAttributes()->get('data-icinga-module')->getValue()] = true; + } + } + + if (Icinga::app()->getModuleManager()->hasInstalled('monitoring')) { + foreach (Hook::all('Monitoring\\' . ucfirst($this->objectType) . 'Actions') as $hook) { + $moduleName = ClassLoader::extractModuleName(get_class($hook)); + if (! isset($nativeExtensionProviders[$moduleName])) { + try { + $navigation->merge($hook->getNavigation($this->compatObject())); + } catch (Exception $e) { + Logger::error("Failed to load legacy action hook: %s\n%s", $e, $e->getTraceAsString()); + $navigation->addItem($moduleName, ['label' => IcingaException::describe($e), 'url' => '#']); + } + } + } + } + + if ($moduleActions->isEmpty() && ($navigation->isEmpty() || ! $navigation->hasRenderableItems())) { + return null; + } + + return [ + Html::tag('h2', t('Actions')), + new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render()), + $moduleActions->isEmpty() ? null : $moduleActions + ]; + } + + protected function createCheckStatistics(): array + { + return [ + Html::tag('h2', t('Check Statistics')), + new CheckStatistics($this->object) + ]; + } + + protected function createComments(): array + { + if ($this->objectType === 'host') { + $link = HostLinks::comments($this->object); + $relations = ['host', 'host.state']; + } else { + $link = ServiceLinks::comments($this->object, $this->object->host); + $relations = ['service', 'service.state', 'service.host', 'service.host.state']; + } + + $comments = $this->object->comment + ->with($relations) + ->limit(3) + ->peekAhead(); + // TODO: This should be automatically done by the model/resolver and added as ON condition + $comments->filter(Filter::equal('object_type', $this->objectType)); + + $comments = $comments->execute(); + /** @var ResultSet $comments */ + + $content = [Html::tag('h2', t('Comments'))]; + + if ($comments->hasResult()) { + $content[] = (new CommentList($comments))->setObjectLinkDisabled()->setTicketLinkEnabled(); + $content[] = (new ShowMore($comments, $link))->setBaseTarget('_next'); + } else { + $content[] = new EmptyState(t('No comments created.')); + } + + return $content; + } + + protected function createCustomVars(): array + { + $content = [Html::tag('h2', t('Custom Variables'))]; + + $this->fetchCustomVars(); + $vars = (new CustomvarFlat())->unFlattenVars($this->object->customvar_flat); + if (! empty($vars)) { + $content[] = new HtmlElement('div', Attributes::create([ + 'id' => $this->objectType . '-customvars', + 'class' => 'collapsible', + 'data-visible-height' => 200 + ]), new CustomVarTable($vars, $this->object)); + } else { + $content[] = new EmptyState(t('No custom variables configured.')); + } + + return $content; + } + + protected function createDowntimes(): array + { + if ($this->objectType === 'host') { + $link = HostLinks::downtimes($this->object); + $relations = ['host', 'host.state']; + } else { + $link = ServiceLinks::downtimes($this->object, $this->object->host); + $relations = ['service', 'service.state', 'service.host', 'service.host.state']; + } + + $downtimes = $this->object->downtime + ->with($relations) + ->limit(3) + ->peekAhead(); + // TODO: This should be automatically done by the model/resolver and added as ON condition + $downtimes->filter(Filter::equal('object_type', $this->objectType)); + + $downtimes = $downtimes->execute(); + /** @var ResultSet $downtimes */ + + $content = [Html::tag('h2', t('Downtimes'))]; + + if ($downtimes->hasResult()) { + $content[] = (new DowntimeList($downtimes))->setObjectLinkDisabled()->setTicketLinkEnabled(); + $content[] = (new ShowMore($downtimes, $link))->setBaseTarget('_next'); + } else { + $content[] = new EmptyState(t('No downtimes scheduled.')); + } + + return $content; + } + + protected function createGroups(): array + { + $groups = [Html::tag('h2', t('Groups'))]; + + if ($this->objectType === 'host') { + $hostgroups = []; + if ($this->isPermittedRoute('hostgroups')) { + $hostgroups = $this->object->hostgroup; + $this->applyRestrictions($hostgroups); + } + + $hostgroupList = new TagList(); + foreach ($hostgroups as $hostgroup) { + $hostgroupList->addLink($hostgroup->display_name, Links::hostgroup($hostgroup)); + } + + $groups[] = $hostgroupList->hasContent() + ? $hostgroupList + : new EmptyState(t('Not a member of any host group.')); + } else { + $servicegroups = []; + if ($this->isPermittedRoute('servicegroups')) { + $servicegroups = $this->object->servicegroup; + $this->applyRestrictions($servicegroups); + } + + $servicegroupList = new TagList(); + foreach ($servicegroups as $servicegroup) { + $servicegroupList->addLink($servicegroup->display_name, Links::servicegroup($servicegroup)); + } + + $groups[] = $servicegroupList->hasContent() + ? $servicegroupList + : new EmptyState(t('Not a member of any service group.')); + } + + return $groups; + } + + protected function createNotes() + { + $navigation = new Navigation(); + $notes = trim($this->object->notes); + + $monitoringInstalled = Icinga::app()->getModuleManager()->hasInstalled('monitoring'); + $obj = $monitoringInstalled ? $this->compatObject() : $this->object; + foreach ($this->object->notes_url->first()->notes_url ?? [] as $url) { + $url = $this->expandMacros($url, $obj); + $navigation->addItem( + Html::wantHtml([ + // Add warning to links that open in new tabs, as recommended by WCAG20 G201 + new Icon('external-link-alt', ['title' => t('Link opens in a new window')]), + $url + ])->render(), + [ + 'target' => '_blank', + 'url' => $url, + 'renderer' => [ + 'NavigationItemRenderer', + 'escape_label' => false + ] + ] + ); + } + + $content = []; + + if (! $navigation->isEmpty() && $navigation->hasRenderableItems()) { + $content[] = new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render()); + } + + if ($notes !== '') { + $content[] = (new MarkdownText($notes)) + ->addAttributes([ + 'class' => 'collapsible', + 'data-visible-height' => 200, + 'id' => $this->objectType . '-notes' + ]); + } + + if (empty($content)) { + return null; + } + + array_unshift($content, Html::tag('h2', t('Notes'))); + + return $content; + } + + protected function createNotifications(): array + { + list($users, $usergroups) = $this->getUsersAndUsergroups(); + + $userList = new TagList(); + $usergroupList = new TagList(); + + foreach ($users as $user) { + $userList->addLink([new Icon(Icons::USER), $user->display_name], Links::user($user)); + } + + foreach ($usergroups as $usergroup) { + $usergroupList->addLink( + [new Icon(Icons::USERGROUP), $usergroup->display_name], + Links::usergroup($usergroup) + ); + } + + return [ + Html::tag('h2', t('Notifications')), + new HorizontalKeyValue( + t('Users'), + $userList->hasContent() ? $userList : new EmptyState(t('No users configured.')) + ), + new HorizontalKeyValue( + t('User Groups'), + $usergroupList->hasContent() + ? $usergroupList + : new EmptyState(t('No user groups configured.')) + ) + ]; + } + + protected function createPerformanceData(): array + { + $content[] = Html::tag('h2', t('Performance Data')); + + if (empty($this->object->state->performance_data)) { + $content[] = new EmptyState(t('No performance data available.')); + } else { + $content[] = new HtmlElement( + 'div', + Attributes::create(['id' => 'check-perfdata-' . $this->object->checkcommand_name]), + new PerfDataTable($this->object->state->normalized_performance_data) + ); + } + + return $content; + } + + protected function createPluginOutput(): array + { + if (empty($this->object->state->output) && empty($this->object->state->long_output)) { + $pluginOutput = new EmptyState(t('Output unavailable.')); + } else { + $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->object)); + CopyToClipboard::attachTo($pluginOutput); + } + + return [ + Html::tag('h2', t('Plugin Output')), + Html::tag( + 'div', + [ + 'id' => 'check-output-' . $this->object->checkcommand_name, + 'class' => 'collapsible', + 'data-visible-height' => 100 + ], + $pluginOutput + ) + ]; + } + + protected function createExtensions(): array + { + $extensions = ObjectDetailExtensionHook::loadExtensions($this->object); + + $nativeExtensionProviders = []; + foreach ($extensions as $extension) { + if ($extension instanceof BaseHtmlElement && $extension->getAttributes()->has('data-icinga-module')) { + $nativeExtensionProviders[$extension->getAttributes()->get('data-icinga-module')->getValue()] = true; + } + } + + if (! Icinga::app()->getModuleManager()->hasInstalled('monitoring')) { + return $extensions; + } + + foreach (Hook::all('Grapher') as $grapher) { + /** @var GrapherHook $grapher */ + $moduleName = ClassLoader::extractModuleName(get_class($grapher)); + + if (isset($nativeExtensionProviders[$moduleName])) { + continue; + } + + try { + $graph = HtmlString::create($grapher->getPreviewHtml($this->compatObject())); + } catch (Exception $e) { + Logger::error("Failed to load legacy grapher: %s\n%s", $e, $e->getTraceAsString()); + $graph = Text::create(IcingaException::describe($e)); + } + + $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::GRAPH_SECTION]; + while (isset($extensions[$location])) { + $location++; + } + + $extensions[$location] = $graph; + } + + foreach (Hook::all('Monitoring\DetailviewExtension') as $extension) { + /** @var DetailviewExtensionHook $extension */ + $moduleName = $extension->getModule()->getName(); + + if (isset($nativeExtensionProviders[$moduleName])) { + continue; + } + + try { + /** @var Web $app */ + $app = Icinga::app(); + $renderedExtension = $extension + ->setView($app->getViewRenderer()->view) + ->getHtmlForObject($this->compatObject()); + + $extensionHtml = new HtmlElement( + 'div', + Attributes::create([ + 'class' => 'icinga-module module-' . $moduleName, + 'data-icinga-module' => $moduleName + ]), + HtmlString::create($renderedExtension) + ); + } catch (Exception $e) { + Logger::error("Failed to load legacy detail extension: %s\n%s", $e, $e->getTraceAsString()); + $extensionHtml = Text::create(IcingaException::describe($e)); + } + + $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::DETAIL_SECTION]; + while (isset($extensions[$location])) { + $location++; + } + + $extensions[$location] = $extensionHtml; + } + + return $extensions; + } + + protected function createFeatureToggles(): array + { + $form = new ToggleObjectFeaturesForm($this->object); + + if ($this->objectType === 'host') { + $form->setAction(HostLinks::toggleFeatures($this->object)->getAbsoluteUrl()); + } else { + $form->setAction(ServiceLinks::toggleFeatures($this->object, $this->object->host)->getAbsoluteUrl()); + } + + return [ + Html::tag('h2', t('Feature Commands')), + $form + ]; + } + + protected function getUsersAndUsergroups(): array + { + $users = []; + $usergroups = []; + $groupBy = false; + + if ($this->objectType === 'host') { + $objectFilter = Filter::all( + Filter::equal('notification.host_id', $this->object->id), + Filter::unlike('notification.service_id', '*') + ); + $objectFilter->metaData()->set('forceOptimization', false); + $groupBy = true; + } else { + $objectFilter = Filter::equal( + 'notification.service_id', + $this->object->id + ); + } + + $userQuery = null; + if ($this->isPermittedRoute('users')) { + $userQuery = User::on($this->getDb()); + $userQuery->filter($objectFilter); + $this->applyRestrictions($userQuery); + if ($groupBy) { + $userQuery->getSelectBase()->groupBy(['user.id']); + } + + foreach ($userQuery as $user) { + $users[$user->name] = $user; + } + } + + if ($this->isPermittedRoute('usergroups')) { + $usergroupQuery = Usergroup::on($this->getDb()); + $usergroupQuery->filter($objectFilter); + $this->applyRestrictions($usergroupQuery); + if ($groupBy && $userQuery !== null) { + $userQuery->getSelectBase()->groupBy(['usergroup.id']); + } + + foreach ($usergroupQuery as $usergroup) { + $usergroups[$usergroup->name] = $usergroup; + } + } + + return [$users, $usergroups]; + } + + protected function fetchCustomVars() + { + $customvarFlat = $this->object->customvar_flat; + if (! $customvarFlat instanceof ResultSet) { + $this->applyRestrictions($customvarFlat); + $customvarFlat->withColumns(['customvar.name', 'customvar.value']); + $this->object->customvar_flat = $customvarFlat->execute(); + } + } +} diff --git a/library/Icingadb/Widget/Detail/ObjectStatistics.php b/library/Icingadb/Widget/Detail/ObjectStatistics.php new file mode 100644 index 0000000..477bf5f --- /dev/null +++ b/library/Icingadb/Widget/Detail/ObjectStatistics.php @@ -0,0 +1,59 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\ValidHtml; +use ipl\Stdlib\BaseFilter; + +abstract class ObjectStatistics extends BaseHtmlElement +{ + use BaseFilter; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'object-statistics']; + + abstract protected function createDonut(): ValidHtml; + + abstract protected function createTotal(): ValidHtml; + + abstract protected function createBadges(): ValidHtml; + + /** + * Shorten the given amount to 4 characters max + * + * @param int $amount + * + * @return string + */ + protected function shortenAmount(int $amount): string + { + if ($amount < 10000) { + return (string) $amount; + } + + if ($amount < 999500) { + return sprintf('%dk', round($amount / 1000.0)); + } + + if ($amount < 9959000) { + return sprintf('%.1fM', $amount / 1000000.0); + } + + // I think we can rule out amounts over 1 Billion + return sprintf('%dM', $amount / 1000000.0); + } + + protected function assemble() + { + $this->add([ + Html::tag('li', ['class' => 'object-statistics-graph'], $this->createDonut()), + Html::tag('li', ['class' => ['object-statistics-total', 'text-center']], $this->createTotal()), + Html::tag('li', $this->createBadges()) + ]); + } +} diff --git a/library/Icingadb/Widget/Detail/ObjectsDetail.php b/library/Icingadb/Widget/Detail/ObjectsDetail.php new file mode 100644 index 0000000..a65dfe6 --- /dev/null +++ b/library/Icingadb/Widget/Detail/ObjectsDetail.php @@ -0,0 +1,190 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Chart\Donut; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm; +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook; +use Icinga\Module\Icingadb\Model\HoststateSummary; +use Icinga\Module\Icingadb\Model\ServicestateSummary; +use Icinga\Module\Icingadb\Util\FeatureStatus; +use Icinga\Module\Icingadb\Widget\HostStateBadges; +use Icinga\Module\Icingadb\Widget\ServiceStateBadges; +use ipl\Orm\Query; +use ipl\Stdlib\BaseFilter; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\VerticalKeyValue; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlString; +use ipl\Web\Widget\ActionLink; + +class ObjectsDetail extends BaseHtmlElement +{ + use BaseFilter; + + protected $summary; + + protected $query; + + protected $type; + + protected $defaultAttributes = ['class' => 'objects-detail']; + + protected $tag = 'div'; + + /** + * Construct an object detail summary widget + * + * @param string $type + * @param HoststateSummary|ServicestateSummary $summary + * @param Query $query + */ + public function __construct(string $type, $summary, Query $query) + { + $this->summary = $summary; + $this->query = $query; + $this->type = $type; + } + + protected function createChart(): BaseHtmlElement + { + $content = Html::tag('div', ['class' => 'multiselect-summary']); + + if ($this->type === 'host') { + $hostsChart = (new Donut()) + ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok']) + ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled']) + ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical']) + ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']); + + $badges = (new HostStateBadges($this->summary)) + ->setBaseFilter($this->getBaseFilter()); + + $content->add([ + HtmlString::create($hostsChart->render()), + new VerticalKeyValue( + tp('Host', 'Hosts', $this->summary->hosts_total), + $this->summary->hosts_total + ), + $badges + ]); + } else { + $servicesChart = (new Donut()) + ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok']) + ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled']) + ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning']) + ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled']) + ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical']) + ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled']) + ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown']) + ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']); + + $badges = (new ServiceStateBadges($this->summary)) + ->setBaseFilter($this->getBaseFilter()); + + $content->add([ + HtmlString::create($servicesChart->render()), + new VerticalKeyValue( + tp('Service', 'Services', $this->summary->services_total), + $this->summary->services_total + ), + $badges + ]); + } + + return $content; + } + + protected function createComments(): array + { + $content = [Html::tag('h2', t('Comments'))]; + + if ($this->summary->comments_total > 0) { + $content[] = new ActionLink( + sprintf( + tp('Show %d comment', 'Show %d comments', $this->summary->comments_total), + $this->summary->comments_total + ), + Links::comments()->setFilter($this->getBaseFilter()) + ); + } else { + $content[] = new EmptyState(t('No comments created.')); + } + + return $content; + } + + protected function createDowntimes(): array + { + $content = [Html::tag('h2', t('Downtimes'))]; + + if ($this->summary->downtimes_total > 0) { + $content[] = new ActionLink( + sprintf( + tp('Show %d downtime', 'Show %d downtimes', $this->summary->downtimes_total), + $this->summary->downtimes_total + ), + Links::downtimes()->setFilter($this->getBaseFilter()) + ); + } else { + $content[] = new EmptyState(t('No downtimes scheduled.')); + } + + return $content; + } + + protected function createFeatureToggles(): array + { + $form = new ToggleObjectFeaturesForm(new FeatureStatus($this->type, $this->summary)); + + if ($this->type === 'host') { + $form->setAction( + Links::toggleHostsFeatures() + ->setFilter($this->getBaseFilter()) + ->getAbsoluteUrl() + ); + } else { + $form->setAction( + Links::toggleServicesFeatures() + ->setFilter($this->getBaseFilter()) + ->getAbsoluteUrl() + ); + } + + return [ + Html::tag('h2', t('Feature Commands')), + $form + ]; + } + + protected function createExtensions(): array + { + return ObjectsDetailExtensionHook::loadExtensions( + $this->type, + $this->query, + $this->getBaseFilter() + ); + } + + protected function createSummary(): array + { + return [ + Html::tag('h2', t('Summary')), + $this->createChart() + ]; + } + + protected function assemble() + { + $this->add(ObjectsDetailExtensionHook::injectExtensions([ + 190 => $this->createSummary(), + 400 => $this->createComments(), + 401 => $this->createDowntimes(), + 701 => $this->createFeatureToggles() + ], $this->createExtensions())); + } +} diff --git a/library/Icingadb/Widget/Detail/PerfDataTable.php b/library/Icingadb/Widget/Detail/PerfDataTable.php new file mode 100644 index 0000000..aaee7c9 --- /dev/null +++ b/library/Icingadb/Widget/Detail/PerfDataTable.php @@ -0,0 +1,130 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Util\PerfData; +use Icinga\Module\Icingadb\Util\PerfDataSet; +use ipl\Html\Attributes; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Table; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Icon; + +class PerfDataTable extends Table +{ + use Translation; + + protected $defaultAttributes = [ + 'class' => 'performance-data-table collapsible', + 'data-visible-rows' => 6 + ]; + + /** @var string The perfdata string */ + protected $perfdataStr; + + /** @var int Max labels to show; 0 for no limit */ + protected $limit; + + /** @var string The color indicating the perfdata state */ + protected $color; + + /** + * Display the given perfdata string to the user + * + * @param string $perfdataStr The perfdata string + * @param int $limit Max labels to show; 0 for no limit + * @param string $color The color indicating the perfdata state + */ + public function __construct(string $perfdataStr, int $limit = 0, string $color = PerfData::PERFDATA_OK) + { + $this->perfdataStr = $perfdataStr; + $this->limit = $limit; + $this->color = $color; + } + + public function assemble() + { + $pieChartData = PerfDataSet::fromString($this->perfdataStr)->asArray(); + $keys = [ + '' => '', + 'label' => t('Label'), + 'value' => t('Value'), + 'min' => t('Min'), + 'max' => t('Max'), + 'warn' => t('Warning'), + 'crit' => t('Critical') + ]; + + $containsSparkline = false; + foreach ($pieChartData as $perfdata) { + if ($perfdata->isVisualizable() || ! $perfdata->isValid()) { + $containsSparkline = true; + break; + } + } + + $headerRow = new HtmlElement('tr'); + foreach ($keys as $key => $col) { + if (! $containsSparkline && $key === '') { + continue; + } + + $headerRow->addHtml(new HtmlElement('th', Attributes::create([ + 'class' => $key === 'label' ? 'title' : null + ]), Text::create($col))); + } + + $this->getHeader()->addHtml($headerRow); + + $count = 0; + foreach ($pieChartData as $perfdata) { + if ($this->limit > 0 && $count === $this->limit) { + break; + } + + $count++; + $cols = []; + if ($containsSparkline) { + if ($perfdata->isVisualizable()) { + $cols[] = Table::td( + HtmlString::create($perfdata->asInlinePie($this->color)->render()), + ['class' => 'sparkline-col'] + ); + } elseif (! $perfdata->isValid()) { + $cols[] = Table::td( + new Icon( + 'triangle-exclamation', + [ + 'title' => $this->translate( + 'Evaluation failed. Performance data is invalid.' + ), + 'class' => ['invalid-perfdata'] + ] + ), + ['class' => 'sparkline-col'] + ); + } else { + $cols[] = Table::td(''); + } + } + + foreach ($perfdata->toArray() as $column => $value) { + $cols[] = Table::td( + new HtmlElement( + 'span', + Attributes::create(['class' => $value ? null : 'no-value']), + $value ? Text::create($value) : new EmptyState(t('None', 'value')) + ), + ['class' => $column === 'label' ? 'title' : null] + ); + } + + $this->addHtml(Table::tr($cols)); + } + } +} diff --git a/library/Icingadb/Widget/Detail/QuickActions.php b/library/Icingadb/Widget/Detail/QuickActions.php new file mode 100644 index 0000000..2ea26c2 --- /dev/null +++ b/library/Icingadb/Widget/Detail/QuickActions.php @@ -0,0 +1,148 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\HostLinks; +use Icinga\Module\Icingadb\Common\ServiceLinks; +use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm; +use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Widget\Icon; + +class QuickActions extends BaseHtmlElement +{ + use Auth; + + /** @var Host|Service */ + protected $object; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'quick-actions']; + + public function __construct($object) + { + $this->object = $object; + } + + protected function assemble() + { + if ($this->object->state->is_problem) { + if ($this->object->state->is_acknowledged) { + if ($this->isGrantedOn('icingadb/command/remove-acknowledgement', $this->object)) { + $removeAckForm = (new RemoveAcknowledgementForm()) + ->setAction($this->getLink('removeAcknowledgement')) + ->setObjects([$this->object]); + + $this->add(Html::tag('li', $removeAckForm)); + } + } elseif ($this->isGrantedOn('icingadb/command/acknowledge-problem', $this->object)) { + $this->assembleAction( + 'acknowledge', + t('Acknowledge'), + 'check-circle', + t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled') + ); + } + } + + if ( + $this->isGrantedOn('icingadb/command/schedule-check', $this->object) + || ( + $this->object->active_checks_enabled + && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object) + ) + ) { + $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow')))); + } + + if ($this->isGrantedOn('icingadb/command/comment/add', $this->object)) { + $this->assembleAction( + 'addComment', + t('Comment', 'verb'), + 'comment', + t('Add a new comment') + ); + } + + if ($this->isGrantedOn('icingadb/command/send-custom-notification', $this->object)) { + $this->assembleAction( + 'sendCustomNotification', + t('Notification'), + 'bell', + t('Send a custom notification') + ); + } + + if ($this->isGrantedOn('icingadb/command/downtime/schedule', $this->object)) { + $this->assembleAction( + 'scheduleDowntime', + t('Downtime'), + 'plug', + t('Schedule a downtime to suppress all problem notifications within a specific period of time') + ); + } + + if ( + $this->isGrantedOn('icingadb/command/schedule-check', $this->object) + || ( + $this->object->active_checks_enabled + && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object) + ) + ) { + $this->assembleAction( + 'scheduleCheck', + t('Reschedule'), + 'calendar', + t('Schedule the next active check at a different time than the current one') + ); + } + + if ($this->isGrantedOn('icingadb/command/process-check-result', $this->object)) { + $this->assembleAction( + 'processCheckresult', + t('Process check result'), + 'edit', + sprintf( + t('Submit a one time or so called passive result for the %s check'), + $this->object->checkcommand_name + ) + ); + } + } + + protected function assembleAction(string $action, string $label, string $icon, string $title) + { + $link = Html::tag( + 'a', + [ + 'href' => $this->getLink($action), + 'class' => 'action-link', + 'title' => $title, + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ], + [ + new Icon($icon), + $label + ] + ); + + $this->add(Html::tag('li', $link)); + } + + protected function getLink($action) + { + if ($this->object instanceof Host) { + return HostLinks::$action($this->object)->getAbsoluteUrl(); + } else { + return ServiceLinks::$action($this->object, $this->object->host)->getAbsoluteUrl(); + } + } +} diff --git a/library/Icingadb/Widget/Detail/ServiceDetail.php b/library/Icingadb/Widget/Detail/ServiceDetail.php new file mode 100644 index 0000000..8421e31 --- /dev/null +++ b/library/Icingadb/Widget/Detail/ServiceDetail.php @@ -0,0 +1,37 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Service; + +class ServiceDetail extends ObjectDetail +{ + public function __construct(Service $object) + { + parent::__construct($object); + } + + protected function assemble() + { + if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') { + $this->add($this->createPrintHeader()); + } + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 0 => $this->createPluginOutput(), + 300 => $this->createActions(), + 301 => $this->createNotes(), + 400 => $this->createComments(), + 401 => $this->createDowntimes(), + 500 => $this->createGroups(), + 501 => $this->createNotifications(), + 600 => $this->createCheckStatistics(), + 601 => $this->createPerformanceData(), + 700 => $this->createCustomVars(), + 701 => $this->createFeatureToggles() + ], $this->createExtensions())); + } +} diff --git a/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php new file mode 100644 index 0000000..f29ee9b --- /dev/null +++ b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Common\ObjectInspectionDetail; + +class ServiceInspectionDetail extends ObjectInspectionDetail +{ + protected function assemble() + { + $this->add([ + $this->createSourceLocation(), + $this->createLastCheckResult(), + $this->createAttributes(), + $this->createCustomVariables(), + $this->createRedisInfo() + ]); + } +} diff --git a/library/Icingadb/Widget/Detail/ServiceMetaInfo.php b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php new file mode 100644 index 0000000..cca7237 --- /dev/null +++ b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Date\DateFormatter; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Web\Widget\VerticalKeyValue; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Web\Widget\Icon; + +class ServiceMetaInfo extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'object-meta-info']; + + /** @var Service */ + protected $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + protected function assemble() + { + $this->addHtml( + new VerticalKeyValue('service.name', $this->service->name), + new VerticalKeyValue( + 'last_state_change', + DateFormatter::formatDateTime($this->service->state->last_state_change->getTimestamp()) + ) + ); + + $collapsible = new HtmlElement('div', Attributes::create([ + 'class' => 'collapsible', + 'id' => 'object-meta-info', + 'data-toggle-element' => '.object-meta-info-control', + 'data-visible-height' => 0 + ])); + + $renderHelper = new HtmlDocument(); + $renderHelper->addHtml( + $this, + new HtmlElement( + 'button', + Attributes::create(['class' => 'object-meta-info-control']), + new Icon('angle-double-up', ['class' => 'collapse-icon']), + new Icon('angle-double-down', ['class' => 'expand-icon']) + ) + ); + + $this->addWrapper($collapsible); + $this->addWrapper($renderHelper); + } +} diff --git a/library/Icingadb/Widget/Detail/ServiceStatistics.php b/library/Icingadb/Widget/Detail/ServiceStatistics.php new file mode 100644 index 0000000..51aced1 --- /dev/null +++ b/library/Icingadb/Widget/Detail/ServiceStatistics.php @@ -0,0 +1,64 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Chart\Donut; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Widget\ServiceStateBadges; +use ipl\Html\ValidHtml; +use ipl\Web\Widget\VerticalKeyValue; +use ipl\Html\HtmlString; +use ipl\Web\Widget\Link; + +class ServiceStatistics extends ObjectStatistics +{ + protected $summary; + + public function __construct($summary) + { + $this->summary = $summary; + } + + protected function createDonut(): ValidHtml + { + $donut = (new Donut()) + ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok']) + ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled']) + ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning']) + ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled']) + ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical']) + ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled']) + ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown']) + ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']); + + return HtmlString::create($donut->render()); + } + + protected function createTotal(): ValidHtml + { + $url = Links::services(); + if ($this->hasBaseFilter()) { + $url->setFilter($this->getBaseFilter()); + } + + return new Link( + (new VerticalKeyValue( + tp('Service', 'Services', $this->summary->services_total), + $this->shortenAmount($this->summary->services_total) + ))->setAttribute('title', $this->summary->services_total), + $url + ); + } + + protected function createBadges(): ValidHtml + { + $badges = new ServiceStateBadges($this->summary); + if ($this->hasBaseFilter()) { + $badges->setBaseFilter($this->getBaseFilter()); + } + + return $badges; + } +} diff --git a/library/Icingadb/Widget/Detail/UserDetail.php b/library/Icingadb/Widget/Detail/UserDetail.php new file mode 100644 index 0000000..0bc1acb --- /dev/null +++ b/library/Icingadb/Widget/Detail/UserDetail.php @@ -0,0 +1,188 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\User; +use Icinga\Module\Icingadb\Widget\ShowMore; +use ipl\Html\Attributes; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\HorizontalKeyValue; +use Icinga\Module\Icingadb\Widget\ItemTable\UsergroupTable; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; + +class UserDetail extends BaseHtmlElement +{ + use Auth; + use Database; + + /** @var User The given user */ + protected $user; + + protected $defaultAttributes = ['class' => 'object-detail']; + + protected $tag = 'div'; + + public function __construct(User $user) + { + $this->user = $user; + } + + protected function createCustomVars(): array + { + $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))]; + $flattenedVars = $this->user->customvar_flat; + $this->applyRestrictions($flattenedVars); + + $vars = $this->user->customvar_flat->getModel()->unflattenVars($flattenedVars); + if (! empty($vars)) { + $content[] = new HtmlElement('div', Attributes::create([ + 'id' => 'user-customvars', + 'class' => 'collapsible', + 'data-visible-height' => 200 + ]), new CustomVarTable($vars, $this->user)); + } else { + $content[] = new EmptyState(t('No custom variables configured.')); + } + + return $content; + } + + protected function createUserDetail(): array + { + list($hostStates, $serviceStates) = $this->separateStates($this->user->states); + $hostStates = implode(', ', $this->localizeStates($hostStates)); + $serviceStates = implode(', ', $this->localizeStates($serviceStates)); + $types = implode(', ', $this->localizeTypes($this->user->types)); + + return [ + new HtmlElement('h2', null, Text::create(t('Details'))), + new HorizontalKeyValue(t('Name'), $this->user->name), + new HorizontalKeyValue(t('E-Mail'), $this->user->email ?: new EmptyState(t('None', 'address'))), + new HorizontalKeyValue(t('Pager'), $this->user->pager ?: new EmptyState(t('None', 'phone-number'))), + new HorizontalKeyValue(t('Host States'), $hostStates ?: t('All')), + new HorizontalKeyValue(t('Service States'), $serviceStates ?: t('All')), + new HorizontalKeyValue(t('Types'), $types ?: t('All')) + ]; + } + + protected function createUsergroupList(): array + { + $userGroups = $this->user->usergroup->limit(6)->peekAhead()->execute(); + + $showMoreLink = (new ShowMore( + $userGroups, + Links::usergroups()->addParams(['user.name' => $this->user->name]) + ))->setBaseTarget('_next'); + + return [ + new HtmlElement('h2', null, Text::create(t('Groups'))), + new UsergroupTable($userGroups), + $showMoreLink + ]; + } + + protected function createExtensions(): array + { + return ObjectDetailExtensionHook::loadExtensions($this->user); + } + + protected function assemble() + { + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 200 => $this->createUserDetail(), + 500 => $this->createUsergroupList(), + 700 => $this->createCustomVars() + ], $this->createExtensions())); + } + + private function localizeTypes(array $types): array + { + $localizedTypes = []; + foreach ($types as $type) { + switch ($type) { + case 'problem': + $localizedTypes[] = t('Problem'); + break; + case 'ack': + $localizedTypes[] = t('Acknowledgement'); + break; + case 'recovery': + $localizedTypes[] = t('Recovery'); + break; + case 'downtime_start': + $localizedTypes[] = t('Downtime Start'); + break; + case 'downtime_end': + $localizedTypes[] = t('Downtime End'); + break; + case 'downtime_removed': + $localizedTypes[] = t('Downtime Removed'); + break; + case 'flapping_start': + $localizedTypes[] = t('Flapping Start'); + break; + case 'flapping_end': + $localizedTypes[] = t('Flapping End'); + break; + case 'custom': + $localizedTypes[] = t('Custom'); + break; + } + } + + return $localizedTypes; + } + + private function localizeStates(array $states): array + { + $localizedState = []; + foreach ($states as $state) { + switch ($state) { + case 'up': + $localizedState[] = t('Up'); + break; + case 'down': + $localizedState[] = t('Down'); + break; + case 'ok': + $localizedState[] = t('Ok'); + break; + case 'warning': + $localizedState[] = t('Warning'); + break; + case 'critical': + $localizedState[] = t('Critical'); + break; + case 'unknown': + $localizedState[] = t('Unknown'); + break; + } + } + + return $localizedState; + } + + private function separateStates(array $states): array + { + $hostStates = []; + $serviceStates = []; + + foreach ($states as $state) { + if ($state === 'Up' || $state === 'Down') { + $hostStates[] = $state; + } else { + $serviceStates[] = $state; + } + } + + return [$hostStates, $serviceStates]; + } +} diff --git a/library/Icingadb/Widget/Detail/UsergroupDetail.php b/library/Icingadb/Widget/Detail/UsergroupDetail.php new file mode 100644 index 0000000..249c795 --- /dev/null +++ b/library/Icingadb/Widget/Detail/UsergroupDetail.php @@ -0,0 +1,98 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\Detail; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Usergroup; +use Icinga\Module\Icingadb\Widget\ItemTable\UserTable; +use Icinga\Module\Icingadb\Widget\ShowMore; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\HorizontalKeyValue; + +class UsergroupDetail extends BaseHtmlElement +{ + use Auth; + use Database; + + /** @var Usergroup The given user group */ + protected $usergroup; + + protected $defaultAttributes = ['class' => 'object-detail']; + + protected $tag = 'div'; + + public function __construct(Usergroup $usergroup) + { + $this->usergroup = $usergroup; + } + + protected function createPrintHeader() + { + return [ + new HtmlElement('h2', null, Text::create(t('Details'))), + new HorizontalKeyValue(t('Name'), $this->usergroup->name) + ]; + } + + protected function createCustomVars(): array + { + $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))]; + $flattenedVars = $this->usergroup->customvar_flat; + $this->applyRestrictions($flattenedVars); + + $vars = $this->usergroup->customvar_flat->getModel()->unflattenVars($flattenedVars); + if (! empty($vars)) { + $content[] = new HtmlElement('div', Attributes::create([ + 'id' => 'usergroup-customvars', + 'class' => 'collapsible', + 'data-visible-height' => 200 + ]), new CustomVarTable($vars, $this->usergroup)); + } else { + $content[] = new EmptyState(t('No custom variables configured.')); + } + + return $content; + } + + protected function createUserList(): array + { + $users = $this->usergroup->user->limit(6)->peekAhead()->execute(); + + $showMoreLink = (new ShowMore( + $users, + Links::users()->addParams(['usergroup.name' => $this->usergroup->name]) + ))->setBaseTarget('_next'); + + return [ + new HtmlElement('h2', null, Text::create(t('Users'))), + new UserTable($users), + $showMoreLink + ]; + } + + protected function createExtensions(): array + { + return ObjectDetailExtensionHook::loadExtensions($this->usergroup); + } + + protected function assemble() + { + if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') { + $this->add($this->createPrintHeader()); + } + + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 500 => $this->createUserList(), + 700 => $this->createCustomVars() + ], $this->createExtensions())); + } +} diff --git a/library/Icingadb/Widget/Health.php b/library/Icingadb/Widget/Health.php new file mode 100644 index 0000000..8c99dca --- /dev/null +++ b/library/Icingadb/Widget/Health.php @@ -0,0 +1,66 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Widget\TimeAgo; +use ipl\Web\Widget\TimeSince; +use ipl\Web\Widget\VerticalKeyValue; + +class Health extends BaseHtmlElement +{ + protected $data; + + protected $tag = 'section'; + + public function __construct($data) + { + $this->data = $data; + } + + protected function assemble() + { + if ($this->data->heartbeat->getTimestamp() > time() - 60) { + $this->add(Html::tag('div', ['class' => 'icinga-health up'], [ + Html::sprintf( + t('Icinga 2 is up and running %s', '...since <timespan>'), + new TimeSince($this->data->icinga2_start_time->getTimestamp()) + ) + ])); + } else { + $this->add(Html::tag('div', ['class' => 'icinga-health down'], [ + Html::sprintf( + t('Icinga 2 or Icinga DB is not running %s', '...since <timespan>'), + new TimeSince($this->data->heartbeat->getTimestamp()) + ) + ])); + } + + $icingaInfo = Html::tag('div', ['class' => 'icinga-info'], [ + new VerticalKeyValue( + t('Icinga 2 Version'), + $this->data->icinga2_version + ), + new VerticalKeyValue( + t('Icinga 2 Start Time'), + new TimeAgo($this->data->icinga2_start_time->getTimestamp()) + ), + new VerticalKeyValue( + t('Last Heartbeat'), + new TimeAgo($this->data->heartbeat->getTimestamp()) + ), + new VerticalKeyValue( + t('Active Icinga 2 Endpoint'), + $this->data->endpoint->name ?: t('N/A') + ), + new VerticalKeyValue( + t('Active Icinga Web Endpoint'), + gethostname() ?: t('N/A') + ) + ]); + $this->add($icingaInfo); + } +} diff --git a/library/Icingadb/Widget/HostStateBadges.php b/library/Icingadb/Widget/HostStateBadges.php new file mode 100644 index 0000000..8141e82 --- /dev/null +++ b/library/Icingadb/Widget/HostStateBadges.php @@ -0,0 +1,45 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Module\Icingadb\Common\HostStates; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\StateBadges; +use ipl\Web\Url; + +class HostStateBadges extends StateBadges +{ + protected function getBaseUrl(): Url + { + return Links::hosts(); + } + + protected function getType(): string + { + return 'host'; + } + + protected function getPrefix(): string + { + return 'hosts'; + } + + protected function getStateInt(string $state): int + { + return HostStates::int($state); + } + + protected function assemble() + { + $this->addAttributes(['class' => 'host-state-badges']); + + $this->add(array_filter([ + $this->createGroup('down'), + $this->createBadge('unknown'), + $this->createBadge('up'), + $this->createBadge('pending') + ])); + } +} diff --git a/library/Icingadb/Widget/HostStatusBar.php b/library/Icingadb/Widget/HostStatusBar.php new file mode 100644 index 0000000..f900f76 --- /dev/null +++ b/library/Icingadb/Widget/HostStatusBar.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Module\Icingadb\Common\BaseStatusBar; +use ipl\Html\BaseHtmlElement; + +class HostStatusBar extends BaseStatusBar +{ + protected function assembleTotal(BaseHtmlElement $total): void + { + $total->add(sprintf(tp('%d Host', '%d Hosts', $this->summary->hosts_total), $this->summary->hosts_total)); + } + + protected function createStateBadges(): BaseHtmlElement + { + return (new HostStateBadges($this->summary))->setBaseFilter($this->getBaseFilter()); + } +} diff --git a/library/Icingadb/Widget/HostSummaryDonut.php b/library/Icingadb/Widget/HostSummaryDonut.php new file mode 100644 index 0000000..db5fef8 --- /dev/null +++ b/library/Icingadb/Widget/HostSummaryDonut.php @@ -0,0 +1,78 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Chart\Donut; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\HoststateSummary; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\TemplateString; +use ipl\Html\Text; +use ipl\Stdlib\BaseFilter; +use ipl\Stdlib\Filter; +use ipl\Web\Common\Card; +use ipl\Web\Filter\QueryString; + +class HostSummaryDonut extends Card +{ + use BaseFilter; + + protected $defaultAttributes = ['class' => 'donut-container', 'data-base-target' => '_next']; + + /** @var HoststateSummary */ + protected $summary; + + public function __construct(HoststateSummary $summary) + { + $this->summary = $summary; + } + + protected function assembleBody(BaseHtmlElement $body) + { + $labelBigUrlFilter = Filter::all( + Filter::equal('host.state.soft_state', 1), + Filter::equal('host.state.is_handled', 'n') + ); + if ($this->hasBaseFilter()) { + $labelBigUrlFilter->add($this->getBaseFilter()); + } + + $donut = (new Donut()) + ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok']) + ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled']) + ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical']) + ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']) + ->setLabelBig($this->summary->hosts_down_unhandled) + ->setLabelBigUrl(Links::hosts()->setFilter($labelBigUrlFilter)->addParams([ + 'sort' => 'host.state.last_state_change' + ])) + ->setLabelBigEyeCatching($this->summary->hosts_down_unhandled > 0) + ->setLabelSmall(t('Down')); + + $body->addHtml( + new HtmlElement('div', Attributes::create(['class' => 'donut']), new HtmlString($donut->render())) + ); + } + + protected function assembleFooter(BaseHtmlElement $footer) + { + $footer->addHtml((new HostStateBadges($this->summary))->setBaseFilter($this->getBaseFilter())); + } + + protected function assembleHeader(BaseHtmlElement $header) + { + $header->addHtml( + new HtmlElement('h2', null, Text::create(t('Hosts'))), + new HtmlElement('span', Attributes::create(['class' => 'meta']), TemplateString::create( + t('{{#total}}Total{{/total}} %d'), + ['total' => new HtmlElement('span')], + (int) $this->summary->hosts_total + )) + ); + } +} diff --git a/library/Icingadb/Widget/IconImage.php b/library/Icingadb/Widget/IconImage.php new file mode 100644 index 0000000..fcf25c8 --- /dev/null +++ b/library/Icingadb/Widget/IconImage.php @@ -0,0 +1,74 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class IconImage extends BaseHtmlElement +{ + /** @var string */ + protected $source; + + /** @var ?string */ + protected $alt; + + protected $tag = 'img'; + + /** + * Create a new icon image + * + * @param string $source + * @param ?string $alt The alternative text + */ + public function __construct(string $source, ?string $alt) + { + $this->source = $source; + $this->alt = $alt; + } + + public function renderUnwrapped() + { + if (! $this->getAttributes()->has('src')) { + // If it's an icon we don't need the <img> tag + return ''; + } + + return parent::renderUnwrapped(); + } + + protected function assemble() + { + $src = $this->source; + + if (strpos($src, '.') === false) { + $this->setWrapper((new HtmlDocument())->addHtml(new Icon($src))); + return; + } + + if (strpos($src, '/') === false) { + $src = 'img/icons/' . $src; + } + + if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') { + $srcUrl = Url::fromPath($src); + $srcPath = $srcUrl->getRelativeUrl(); + if (! $srcUrl->isExternal() && file_exists($srcPath) && is_file($srcPath)) { + $mimeType = @mime_content_type($srcPath); + $content = @file_get_contents($srcPath); + if ($mimeType !== false && $content !== false) { + $src = "data:$mimeType;base64," . base64_encode($content); + } + } + } + + $this->addAttributes([ + 'src' => $src, + 'alt' => $this->alt + ]); + } +} diff --git a/library/Icingadb/Widget/ItemList/BaseCommentListItem.php b/library/Icingadb/Widget/ItemList/BaseCommentListItem.php new file mode 100644 index 0000000..de11c0c --- /dev/null +++ b/library/Icingadb/Widget/ItemList/BaseCommentListItem.php @@ -0,0 +1,131 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\ObjectLinkDisabled; +use Icinga\Module\Icingadb\Common\TicketLinks; +use ipl\Html\Html; +use Icinga\Module\Icingadb\Common\HostLink; +use Icinga\Module\Icingadb\Common\Icons; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Widget\MarkdownLine; +use Icinga\Module\Icingadb\Common\ServiceLink; +use Icinga\Module\Icingadb\Model\Comment; +use ipl\Html\FormattedString; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Widget\TimeAgo; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\TimeUntil; + +/** + * Comment item of a comment list. Represents one database row. + * + * @property Comment $item + * @property CommentList $list + */ +abstract class BaseCommentListItem extends BaseListItem +{ + use HostLink; + use ServiceLink; + use NoSubjectLink; + use ObjectLinkDisabled; + use TicketLinks; + + protected function assembleCaption(BaseHtmlElement $caption): void + { + $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->text)); + + $caption->getAttributes()->add($markdownLine->getAttributes()); + $caption->addFrom($markdownLine); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $isAck = $this->item->entry_type === 'ack'; + $expires = $this->item->expire_time; + + $subjectText = sprintf( + $isAck ? t('%s acknowledged', '<username>..') : t('%s commented', '<username>..'), + $this->item->author + ); + + $headerParts = [ + new Icon(Icons::USER), + $this->getNoSubjectLink() + ? new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($subjectText)) + : new Link($subjectText, Links::comment($this->item), ['class' => 'subject']) + ]; + + if ($isAck) { + $label = [Text::create('ack')]; + + if ($this->item->is_persistent) { + array_unshift($label, new Icon(Icons::IS_PERSISTENT)); + } + + $headerParts[] = Text::create(' '); + $headerParts[] = new HtmlElement('span', Attributes::create(['class' => 'ack-badge badge']), ...$label); + } + + if ($expires !== null) { + $headerParts[] = Text::create(' '); + $headerParts[] = new HtmlElement( + 'span', + Attributes::create(['class' => 'ack-badge badge']), + Text::create(t('EXPIRES')) + ); + } + + if ($this->getObjectLinkDisabled()) { + // pass + } elseif ($this->item->object_type === 'host') { + $headerParts[] = $this->createHostLink($this->item->host, true); + } else { + $headerParts[] = $this->createServiceLink($this->item->service, $this->item->service->host, true); + } + + $title->addHtml(...$headerParts); + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $visual->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => 'user-ball']), + Text::create($this->item->author[0]) + )); + } + + protected function createTimestamp(): ?BaseHtmlElement + { + if ($this->item->expire_time) { + return Html::tag( + 'span', + FormattedString::create(t("expires %s"), new TimeUntil($this->item->expire_time->getTimestamp())) + ); + } + + return Html::tag( + 'span', + FormattedString::create(t("created %s"), new TimeAgo($this->item->entry_time->getTimestamp())) + ); + } + + protected function init(): void + { + $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled()); + $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); + $this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name)); + $this->setObjectLinkDisabled($this->list->getObjectLinkDisabled()); + $this->setNoSubjectLink($this->list->getNoSubjectLink()); + } +} diff --git a/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php new file mode 100644 index 0000000..7ebc1f6 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php @@ -0,0 +1,212 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Date\DateFormatter; +use Icinga\Module\Icingadb\Common\HostLink; +use Icinga\Module\Icingadb\Common\Icons; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\ObjectLinkDisabled; +use Icinga\Module\Icingadb\Common\ServiceLink; +use Icinga\Module\Icingadb\Common\TicketLinks; +use Icinga\Module\Icingadb\Model\Downtime; +use Icinga\Module\Icingadb\Widget\MarkdownLine; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\TemplateString; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +/** + * Downtime item of a downtime list. Represents one database row. + * + * @property Downtime $item + * @property DowntimeList $list + */ +abstract class BaseDowntimeListItem extends BaseListItem +{ + use HostLink; + use ServiceLink; + use NoSubjectLink; + use ObjectLinkDisabled; + use TicketLinks; + + /** @var int Current Time */ + protected $currentTime; + + /** @var int Duration */ + protected $duration; + + /** @var int Downtime end time */ + protected $endTime; + + /** @var bool Whether the downtime is active */ + protected $isActive; + + /** @var int Downtime start time */ + protected $startTime; + + protected function init(): void + { + if ($this->item->is_flexible && $this->item->is_in_effect) { + $this->startTime = $this->item->start_time->getTimestamp(); + $this->endTime = $this->item->end_time->getTimestamp(); + } else { + $this->startTime = $this->item->scheduled_start_time->getTimestamp(); + $this->endTime = $this->item->scheduled_end_time->getTimestamp(); + } + + $this->currentTime = time(); + + $this->isActive = $this->item->is_in_effect + || $this->item->is_flexible && $this->item->scheduled_start_time->getTimestamp() <= $this->currentTime; + + $until = ($this->isActive ? $this->endTime : $this->startTime) - $this->currentTime; + $this->duration = explode(' ', DateFormatter::formatDuration( + $until <= 3600 ? $until : $until + (3600 - ((int) $until % 3600)) + ), 2)[0]; + + $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); + $this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name)); + $this->setObjectLinkDisabled($this->list->getObjectLinkDisabled()); + $this->setNoSubjectLink($this->list->getNoSubjectLink()); + $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled()); + + if ($this->item->is_in_effect) { + $this->getAttributes()->add('class', 'in-effect'); + } + } + + protected function createProgress(): BaseHtmlElement + { + return new HtmlElement( + 'div', + Attributes::create([ + 'class' => 'progress', + 'data-animate-progress' => true, + 'data-start-time' => $this->startTime, + 'data-end-time' => $this->endTime + ]), + new HtmlElement( + 'div', + Attributes::create(['class' => 'bar']) + ) + ); + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->comment)); + $caption->getAttributes()->add($markdownLine->getAttributes()); + $caption->addHtml( + new HtmlElement( + 'span', + null, + new Icon(Icons::USER), + Text::create($this->item->author) + ), + Text::create(': ') + )->addFrom($markdownLine); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + if ($this->getObjectLinkDisabled()) { + $link = null; + } elseif ($this->item->object_type === 'host') { + $link = $this->createHostLink($this->item->host, true); + } else { + $link = $this->createServiceLink($this->item->service, $this->item->service->host, true); + } + + if ($this->item->is_flexible) { + if ($link !== null) { + $template = t('{{#link}}Flexible Downtime{{/link}} for %s'); + } else { + $template = t('Flexible Downtime'); + } + } else { + if ($link !== null) { + $template = t('{{#link}}Fixed Downtime{{/link}} for %s'); + } else { + $template = t('Fixed Downtime'); + } + } + + if ($this->getNoSubjectLink()) { + if ($link === null) { + $title->addHtml(HtmlElement::create('span', [ 'class' => 'subject'], $template)); + } else { + $title->addHtml(TemplateString::create( + $template, + ['link' => HtmlElement::create('span', [ 'class' => 'subject'])], + $link + )); + } + } else { + if ($link === null) { + $title->addHtml(new Link($template, Links::downtime($this->item))); + } else { + $title->addHtml(TemplateString::create( + $template, + ['link' => new Link('', Links::downtime($this->item))], + $link + )); + } + } + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $dateTime = DateFormatter::formatDateTime($this->endTime); + + if ($this->isActive) { + $visual->addHtml(Html::sprintf( + t('%s left', '<timespan>..'), + Html::tag( + 'strong', + Html::tag( + 'time', + [ + 'datetime' => $dateTime, + 'title' => $dateTime + ], + $this->duration + ) + ) + )); + } else { + $visual->addHtml(Html::sprintf( + t('in %s', '..<timespan>'), + Html::tag('strong', $this->duration) + )); + } + } + + protected function createTimestamp(): ?BaseHtmlElement + { + $dateTime = DateFormatter::formatDateTime($this->isActive ? $this->endTime : $this->startTime); + + return Html::tag( + 'time', + [ + 'datetime' => $dateTime, + 'title' => $dateTime + ], + sprintf( + $this->isActive + ? t('expires in %s', '..<timespan>') + : t('starts in %s', '..<timespan>'), + $this->duration + ) + ); + } +} diff --git a/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php b/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php new file mode 100644 index 0000000..6999324 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php @@ -0,0 +1,405 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Date\DateFormatter; +use Icinga\Module\Icingadb\Common\HostLink; +use Icinga\Module\Icingadb\Common\HostStates; +use Icinga\Module\Icingadb\Common\Icons; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\TicketLinks; +use Icinga\Module\Icingadb\Widget\MarkdownLine; +use Icinga\Module\Icingadb\Common\ServiceLink; +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Module\Icingadb\Model\History; +use Icinga\Module\Icingadb\Util\PluginOutput; +use Icinga\Module\Icingadb\Widget\CheckAttempt; +use Icinga\Module\Icingadb\Widget\PluginOutputContainer; +use Icinga\Module\Icingadb\Widget\StateChange; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\StateBall; +use ipl\Web\Widget\TimeAgo; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +abstract class BaseHistoryListItem extends BaseListItem +{ + use HostLink; + use NoSubjectLink; + use ServiceLink; + use TicketLinks; + + /** @var History */ + protected $item; + + /** @var HistoryList */ + protected $list; + + protected function init(): void + { + $this->setNoSubjectLink($this->list->getNoSubjectLink()); + $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled()); + $this->list->addDetailFilterAttribute($this, Filter::equal('id', bin2hex($this->item->id))); + } + + abstract protected function getStateBallSize(): string; + + protected function assembleCaption(BaseHtmlElement $caption): void + { + switch ($this->item->event_type) { + case 'comment_add': + case 'comment_remove': + $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->comment->comment)); + $caption->getAttributes()->add($markdownLine->getAttributes()); + $caption->add([ + new Icon(Icons::USER), + $this->item->comment->author, + ': ' + ])->addFrom($markdownLine); + + break; + case 'downtime_end': + case 'downtime_start': + $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->downtime->comment)); + $caption->getAttributes()->add($markdownLine->getAttributes()); + $caption->add([ + new Icon(Icons::USER), + $this->item->downtime->author, + ': ' + ])->addFrom($markdownLine); + + break; + case 'flapping_start': + $caption + ->add(sprintf( + t('State Change Rate: %.2f%%; Start Threshold: %.2f%%'), + $this->item->flapping->percent_state_change_start, + $this->item->flapping->flapping_threshold_high + )) + ->getAttributes() + ->add('class', 'plugin-output'); + + break; + case 'flapping_end': + $caption + ->add(sprintf( + t('State Change Rate: %.2f%%; End Threshold: %.2f%%; Flapping for %s'), + $this->item->flapping->percent_state_change_end, + $this->item->flapping->flapping_threshold_low, + DateFormatter::formatDuration( + $this->item->flapping->end_time->getTimestamp() + - $this->item->flapping->start_time->getTimestamp() + ) + )) + ->getAttributes() + ->add('class', 'plugin-output'); + + break; + case 'ack_clear': + case 'ack_set': + if (! isset($this->item->acknowledgement->comment) && ! isset($this->item->acknowledgement->author)) { + $caption->addHtml(new EmptyState( + t('This acknowledgement was set before Icinga DB history recording') + )); + } else { + $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->acknowledgement->comment)); + $caption->getAttributes()->add($markdownLine->getAttributes()); + $caption->add([ + new Icon(Icons::USER), + $this->item->acknowledgement->author, + ': ' + ])->addFrom($markdownLine); + } + + break; + case 'notification': + if (! empty($this->item->notification->author)) { + $caption->add([ + new Icon(Icons::USER), + $this->item->notification->author, + ': ', + $this->item->notification->text + ]); + } else { + $commandName = $this->item->object_type === 'host' + ? $this->item->host->checkcommand_name + : $this->item->service->checkcommand_name; + if (isset($commandName)) { + if (empty($this->item->notification->text)) { + $caption->addHtml(new EmptyState(t('Output unavailable.'))); + } else { + $caption->addHtml(new PluginOutputContainer( + (new PluginOutput($this->item->notification->text)) + ->setCommandName($commandName) + )); + } + } else { + $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.'))); + } + } + + break; + case 'state_change': + $commandName = $this->item->object_type === 'host' + ? $this->item->host->checkcommand_name + : $this->item->service->checkcommand_name; + if (isset($commandName)) { + if (empty($this->item->state->output)) { + $caption->addHtml(new EmptyState(t('Output unavailable.'))); + } else { + $caption->addHtml(new PluginOutputContainer( + (new PluginOutput($this->item->state->output)) + ->setCommandName($commandName) + )); + } + } else { + $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.'))); + } + + break; + } + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + switch ($this->item->event_type) { + case 'comment_add': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::COMMENT) + )); + + break; + case 'comment_remove': + case 'downtime_end': + case 'ack_clear': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::REMOVE) + )); + + break; + case 'downtime_start': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::IN_DOWNTIME) + )); + + break; + case 'ack_set': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::IS_ACKNOWLEDGED) + )); + + break; + case 'flapping_end': + case 'flapping_start': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::IS_FLAPPING) + )); + + break; + case 'notification': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::NOTIFICATION) + )); + + break; + case 'state_change': + if ($this->item->state->state_type === 'soft') { + $stateType = 'soft_state'; + $previousStateType = 'previous_soft_state'; + + if ($this->item->state->previous_soft_state === 0) { + $previousStateType = 'hard_state'; + } + + $visual->addHtml(new CheckAttempt( + (int) $this->item->state->check_attempt, + (int) $this->item->state->max_check_attempts + )); + } else { + $stateType = 'hard_state'; + $previousStateType = 'previous_hard_state'; + + if ($this->item->state->hard_state === $this->item->state->previous_hard_state) { + $previousStateType = 'previous_soft_state'; + } + } + + if ($this->item->object_type === 'host') { + $state = HostStates::text($this->item->state->$stateType); + $previousState = HostStates::text($this->item->state->$previousStateType); + } else { + $state = ServiceStates::text($this->item->state->$stateType); + $previousState = ServiceStates::text($this->item->state->$previousStateType); + } + + $stateChange = new StateChange($state, $previousState); + if ($stateType === 'soft_state') { + $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE); + } + + if ($previousStateType === 'previous_soft_state') { + $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE); + if ($stateType === 'soft_state') { + $visual->getAttributes()->add('class', 'small-state-change'); + } + } + + $visual->prependHtml($stateChange); + + break; + } + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + switch ($this->item->event_type) { + case 'comment_add': + $subjectLabel = t('Comment added'); + + break; + case 'comment_remove': + if (! empty($this->item->comment->removed_by)) { + if ($this->item->comment->removed_by !== $this->item->comment->author) { + $subjectLabel = sprintf( + t('Comment removed by %s', '..<username>'), + $this->item->comment->removed_by + ); + } else { + $subjectLabel = t('Comment removed by author'); + } + } elseif (isset($this->item->comment->expire_time)) { + $subjectLabel = t('Comment expired'); + } else { + $subjectLabel = t('Comment removed'); + } + + break; + case 'downtime_end': + if (! empty($this->item->downtime->cancelled_by)) { + if ($this->item->downtime->cancelled_by !== $this->item->downtime->author) { + $subjectLabel = sprintf( + t('Downtime cancelled by %s', '..<username>'), + $this->item->downtime->cancelled_by + ); + } else { + $subjectLabel = t('Downtime cancelled by author'); + } + } elseif ($this->item->downtime->has_been_cancelled === 'y') { + $subjectLabel = t('Downtime cancelled'); + } else { + $subjectLabel = t('Downtime ended'); + } + + break; + case 'downtime_start': + $subjectLabel = t('Downtime started'); + + break; + case 'flapping_start': + $subjectLabel = t('Flapping started'); + + break; + case 'flapping_end': + $subjectLabel = t('Flapping stopped'); + + break; + case 'ack_set': + $subjectLabel = t('Acknowledgement set'); + + break; + case 'ack_clear': + if (! empty($this->item->acknowledgement->cleared_by)) { + if ($this->item->acknowledgement->cleared_by !== $this->item->acknowledgement->author) { + $subjectLabel = sprintf( + t('Acknowledgement cleared by %s', '..<username>'), + $this->item->acknowledgement->cleared_by + ); + } else { + $subjectLabel = t('Acknowledgement cleared by author'); + } + } elseif (isset($this->item->acknowledgement->expire_time)) { + $subjectLabel = t('Acknowledgement expired'); + } else { + $subjectLabel = t('Acknowledgement cleared'); + } + + break; + case 'notification': + $subjectLabel = sprintf( + NotificationListItem::phraseForType($this->item->notification->type), + ucfirst($this->item->object_type) + ); + + break; + case 'state_change': + $state = $this->item->state->state_type === 'hard' + ? $this->item->state->hard_state + : $this->item->state->soft_state; + if ($state === 0) { + if ($this->item->object_type === 'service') { + $subjectLabel = t('Service recovered'); + } else { + $subjectLabel = t('Host recovered'); + } + } else { + if ($this->item->state->state_type === 'hard') { + $subjectLabel = t('Hard state changed'); + } else { + $subjectLabel = t('Soft state changed'); + } + } + + break; + default: + $subjectLabel = $this->item->event_type; + + break; + } + + if ($this->getNoSubjectLink()) { + $title->addHtml(HtmlElement::create('span', ['class' => 'subject'], $subjectLabel)); + } else { + $title->addHtml(new Link($subjectLabel, Links::event($this->item), ['class' => 'subject'])); + } + + if ($this->item->object_type === 'host') { + if (isset($this->item->host->id)) { + $link = $this->createHostLink($this->item->host, true); + } + } else { + if (isset($this->item->host->id, $this->item->service->id)) { + $link = $this->createServiceLink($this->item->service, $this->item->host, true); + } + } + + $title->addHtml(Text::create(' ')); + if (isset($link)) { + $title->addHtml($link); + } + } + + protected function createTimestamp(): ?BaseHtmlElement + { + return new TimeAgo($this->item->event_time->getTimestamp()); + } +} diff --git a/library/Icingadb/Widget/ItemList/BaseHostListItem.php b/library/Icingadb/Widget/ItemList/BaseHostListItem.php new file mode 100644 index 0000000..edaf6c8 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/BaseHostListItem.php @@ -0,0 +1,56 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Model\Host; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Widget\Link; + +/** + * Host item of a host list. Represents one database row. + * + * @property Host $item + * @property HostList $list + */ +abstract class BaseHostListItem extends StateListItem +{ + use NoSubjectLink; + + /** + * Create new subject link + * + * @return BaseHtmlElement + */ + protected function createSubject() + { + if ($this->getNoSubjectLink()) { + return new HtmlElement( + 'span', + Attributes::create(['class' => 'subject']), + Text::create($this->item->display_name) + ); + } else { + return new Link($this->item->display_name, Links::host($this->item), ['class' => 'subject']); + } + } + + protected function init(): void + { + parent::init(); + + if ($this->list->getNoSubjectLink()) { + $this->setNoSubjectLink(); + } + + $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)) + ->addMultiselectFilterAttribute($this, Filter::equal('host.name', $this->item->name)); + } +} diff --git a/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php b/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php new file mode 100644 index 0000000..b538ac4 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php @@ -0,0 +1,189 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\HostLink; +use Icinga\Module\Icingadb\Common\HostStates; +use Icinga\Module\Icingadb\Common\Icons; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\ServiceLink; +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Module\Icingadb\Util\PluginOutput; +use Icinga\Module\Icingadb\Widget\PluginOutputContainer; +use Icinga\Module\Icingadb\Widget\StateChange; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\TimeAgo; +use InvalidArgumentException; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +abstract class BaseNotificationListItem extends BaseListItem +{ + use HostLink; + use NoSubjectLink; + use ServiceLink; + + /** @var NotificationList */ + protected $list; + + protected function init(): void + { + $this->setNoSubjectLink($this->list->getNoSubjectLink()); + $this->list->addDetailFilterAttribute($this, Filter::equal('id', bin2hex($this->item->history->id))); + } + + /** + * Get a localized phrase for the given notification type + * + * @param string $type + * + * @return string + */ + public static function phraseForType(string $type): string + { + switch ($type) { + case 'acknowledgement': + return t('Problem acknowledged'); + case 'custom': + return t('Custom Notification triggered'); + case 'downtime_end': + return t('Downtime ended'); + case 'downtime_removed': + return t('Downtime removed'); + case 'downtime_start': + return t('Downtime started'); + case 'flapping_end': + return t('Flapping stopped'); + case 'flapping_start': + return t('Flapping started'); + case 'problem': + return t('%s ran into a problem'); + case 'recovery': + return t('%s recovered'); + default: + throw new InvalidArgumentException(sprintf('Type %s is not a valid notification type', $type)); + } + } + + abstract protected function getStateBallSize(); + + protected function assembleCaption(BaseHtmlElement $caption): void + { + if (in_array($this->item->type, ['flapping_end', 'flapping_start', 'problem', 'recovery'])) { + $commandName = $this->item->object_type === 'host' + ? $this->item->host->checkcommand_name + : $this->item->service->checkcommand_name; + if (isset($commandName)) { + if (empty($this->item->text)) { + $caption->addHtml(new EmptyState(t('Output unavailable.'))); + } else { + $caption->addHtml(new PluginOutputContainer( + (new PluginOutput($this->item->text)) + ->setCommandName($commandName) + )); + } + } else { + $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.'))); + } + } else { + $caption->add([ + new Icon(Icons::USER), + $this->item->author, + ': ', + $this->item->text + ]); + } + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + switch ($this->item->type) { + case 'acknowledgement': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::IS_ACKNOWLEDGED) + )); + + break; + case 'custom': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::NOTIFICATION) + )); + + break; + case 'downtime_end': + case 'downtime_removed': + case 'downtime_start': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::IN_DOWNTIME) + )); + + break; + case 'flapping_end': + case 'flapping_start': + $visual->addHtml(HtmlElement::create( + 'div', + ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]], + new Icon(Icons::IS_FLAPPING) + )); + + break; + case 'problem': + case 'recovery': + if ($this->item->object_type === 'host') { + $state = HostStates::text($this->item->state); + $previousHardState = HostStates::text($this->item->previous_hard_state); + } else { + $state = ServiceStates::text($this->item->state); + $previousHardState = ServiceStates::text($this->item->previous_hard_state); + } + + $visual->addHtml(new StateChange($state, $previousHardState)); + + break; + } + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + if ($this->getNoSubjectLink()) { + $title->addHtml(HtmlElement::create( + 'span', + ['class' => 'subject'], + sprintf(self::phraseForType($this->item->type), ucfirst($this->item->object_type)) + )); + } else { + $title->addHtml(new Link( + sprintf(self::phraseForType($this->item->type), ucfirst($this->item->object_type)), + Links::event($this->item->history), + ['class' => 'subject'] + )); + } + + if ($this->item->object_type === 'host') { + $link = $this->createHostLink($this->item->host, true); + } else { + $link = $this->createServiceLink($this->item->service, $this->item->host, true); + } + + $title->addHtml(Text::create(' '), $link); + } + + protected function createTimestamp(): ?BaseHtmlElement + { + return new TimeAgo($this->item->send_time->getTimestamp()); + } +} diff --git a/library/Icingadb/Widget/ItemList/BaseServiceListItem.php b/library/Icingadb/Widget/ItemList/BaseServiceListItem.php new file mode 100644 index 0000000..fe4f014 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/BaseServiceListItem.php @@ -0,0 +1,70 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Html\Attributes; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBall; + +/** + * Service item of a service list. Represents one database row. + * + * @property Service $item + * @property ServiceList $list + */ +abstract class BaseServiceListItem extends StateListItem +{ + use NoSubjectLink; + + protected function createSubject() + { + $service = $this->item->display_name; + $host = [ + new StateBall($this->item->host->state->getStateText(), StateBall::SIZE_MEDIUM), + ' ', + $this->item->host->display_name + ]; + + $host = new Link($host, Links::host($this->item->host), ['class' => 'subject']); + if ($this->getNoSubjectLink()) { + $service = new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($service)); + } else { + $service = new Link($service, Links::service($this->item, $this->item->host), ['class' => 'subject']); + } + + return [Html::sprintf(t('%s on %s', '<service> on <host>'), $service, $host)]; + } + + protected function init(): void + { + parent::init(); + + if ($this->list->getNoSubjectLink()) { + $this->setNoSubjectLink(); + } + + $this->list->addMultiselectFilterAttribute( + $this, + Filter::all( + Filter::equal('service.name', $this->item->name), + Filter::equal('host.name', $this->item->host->name) + ) + ); + $this->list->addDetailFilterAttribute( + $this, + Filter::all( + Filter::equal('name', $this->item->name), + Filter::equal('host.name', $this->item->host->name) + ) + ); + } +} diff --git a/library/Icingadb/Widget/ItemList/CommandTransportList.php b/library/Icingadb/Widget/ItemList/CommandTransportList.php new file mode 100644 index 0000000..61d771d --- /dev/null +++ b/library/Icingadb/Widget/ItemList/CommandTransportList.php @@ -0,0 +1,26 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\DetailActions; +use ipl\Web\Common\BaseOrderedItemList; +use ipl\Web\Url; + +class CommandTransportList extends BaseOrderedItemList +{ + use DetailActions; + + protected function init(): void + { + $this->getAttributes()->add('class', 'command-transport-list'); + $this->initializeDetailActions(); + $this->setDetailUrl(Url::fromPath('icingadb/command-transport/show')); + } + + protected function getItemClass(): string + { + return CommandTransportListItem::class; + } +} diff --git a/library/Icingadb/Widget/ItemList/CommandTransportListItem.php b/library/Icingadb/Widget/ItemList/CommandTransportListItem.php new file mode 100644 index 0000000..9873403 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/CommandTransportListItem.php @@ -0,0 +1,71 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseOrderedListItem; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +class CommandTransportListItem extends BaseOrderedListItem +{ + protected function init(): void + { + $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); + } + + protected function assembleHeader(BaseHtmlElement $header): void + { + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->addHtml(new Link( + new HtmlElement('strong', null, Text::create($this->item->name)), + Url::fromPath('icingadb/command-transport/show', ['name' => $this->item->name]) + )); + + $main->addHtml(new Link( + new Icon('trash', ['title' => sprintf(t('Remove command transport "%s"'), $this->item->name)]), + Url::fromPath('icingadb/command-transport/remove', ['name' => $this->item->name]), + [ + 'class' => 'pull-right action-link', + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ] + )); + + if ($this->getOrder() + 1 < $this->list->count()) { + $main->addHtml((new Link( + new Icon('arrow-down'), + Url::fromPath('icingadb/command-transport/sort', [ + 'name' => $this->item->name, + 'pos' => $this->getOrder() + 1 + ]), + ['class' => 'pull-right action-link'] + ))->setBaseTarget('_self')); + } + + if ($this->getOrder() > 0) { + $main->addHtml((new Link( + new Icon('arrow-up'), + Url::fromPath('icingadb/command-transport/sort', [ + 'name' => $this->item->name, + 'pos' => $this->getOrder() - 1 + ]), + ['class' => 'pull-right action-link'] + ))->setBaseTarget('_self')); + } + } + + protected function createVisual(): ?BaseHtmlElement + { + return null; + } +} diff --git a/library/Icingadb/Widget/ItemList/CommentList.php b/library/Icingadb/Widget/ItemList/CommentList.php new file mode 100644 index 0000000..5cf65ae --- /dev/null +++ b/library/Icingadb/Widget/ItemList/CommentList.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\CaptionDisabled; +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\ObjectLinkDisabled; +use Icinga\Module\Icingadb\Common\TicketLinks; +use Icinga\Module\Icingadb\Common\ViewMode; +use ipl\Web\Common\BaseItemList; +use ipl\Web\Url; + +class CommentList extends BaseItemList +{ + use CaptionDisabled; + use NoSubjectLink; + use ObjectLinkDisabled; + use ViewMode; + use TicketLinks; + use DetailActions; + + protected $defaultAttributes = ['class' => 'comment-list']; + + protected function getItemClass(): string + { + $viewMode = $this->getViewMode(); + + $this->addAttributes(['class' => $viewMode]); + + if ($viewMode === 'minimal') { + return CommentListItemMinimal::class; + } elseif ($viewMode === 'detailed') { + $this->removeAttribute('class', 'default-layout'); + } + + return CommentListItem::class; + } + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setMultiselectUrl(Links::commentsDetails()); + $this->setDetailUrl(Url::fromPath('icingadb/comment')); + } +} diff --git a/library/Icingadb/Widget/ItemList/CommentListItem.php b/library/Icingadb/Widget/ItemList/CommentListItem.php new file mode 100644 index 0000000..3bbd0c2 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/CommentListItem.php @@ -0,0 +1,12 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemCommonLayout; + +class CommentListItem extends BaseCommentListItem +{ + use ListItemCommonLayout; +} diff --git a/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php b/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php new file mode 100644 index 0000000..3c23ccd --- /dev/null +++ b/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemMinimalLayout; + +class CommentListItemMinimal extends BaseCommentListItem +{ + use ListItemMinimalLayout; + + protected function init(): void + { + parent::init(); + + if ($this->list->isCaptionDisabled()) { + $this->setCaptionDisabled(); + } + } +} diff --git a/library/Icingadb/Widget/ItemList/DowntimeList.php b/library/Icingadb/Widget/ItemList/DowntimeList.php new file mode 100644 index 0000000..591ad98 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/DowntimeList.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\CaptionDisabled; +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\ObjectLinkDisabled; +use Icinga\Module\Icingadb\Common\TicketLinks; +use Icinga\Module\Icingadb\Common\ViewMode; +use ipl\Web\Common\BaseItemList; +use ipl\Web\Url; + +class DowntimeList extends BaseItemList +{ + use CaptionDisabled; + use NoSubjectLink; + use ObjectLinkDisabled; + use ViewMode; + use TicketLinks; + use DetailActions; + + protected $defaultAttributes = ['class' => 'downtime-list']; + + protected function getItemClass(): string + { + $viewMode = $this->getViewMode(); + + $this->addAttributes(['class' => $viewMode]); + + if ($viewMode === 'minimal') { + return DowntimeListItemMinimal::class; + } elseif ($viewMode === 'detailed') { + $this->removeAttribute('class', 'default-layout'); + } + + return DowntimeListItem::class; + } + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setMultiselectUrl(Links::downtimesDetails()); + $this->setDetailUrl(Url::fromPath('icingadb/downtime')); + } +} diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItem.php b/library/Icingadb/Widget/ItemList/DowntimeListItem.php new file mode 100644 index 0000000..cb7e9b3 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/DowntimeListItem.php @@ -0,0 +1,23 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemCommonLayout; +use ipl\Html\BaseHtmlElement; + +class DowntimeListItem extends BaseDowntimeListItem +{ + use ListItemCommonLayout; + + protected function assembleMain(BaseHtmlElement $main): void + { + if ($this->item->is_in_effect) { + $main->add($this->createProgress()); + } + + $main->add($this->createHeader()); + $main->add($this->createCaption()); + } +} diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php b/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php new file mode 100644 index 0000000..b8581d2 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php @@ -0,0 +1,21 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemMinimalLayout; + +class DowntimeListItemMinimal extends BaseDowntimeListItem +{ + use ListItemMinimalLayout; + + protected function init(): void + { + parent::init(); + + if ($this->list->isCaptionDisabled()) { + $this->setCaptionDisabled(); + } + } +} diff --git a/library/Icingadb/Widget/ItemList/HistoryList.php b/library/Icingadb/Widget/ItemList/HistoryList.php new file mode 100644 index 0000000..d3b6232 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HistoryList.php @@ -0,0 +1,57 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\CaptionDisabled; +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\LoadMore; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\TicketLinks; +use Icinga\Module\Icingadb\Common\ViewMode; +use ipl\Orm\ResultSet; +use ipl\Web\Common\BaseItemList; +use ipl\Web\Url; + +class HistoryList extends BaseItemList +{ + use CaptionDisabled; + use NoSubjectLink; + use ViewMode; + use LoadMore; + use TicketLinks; + use DetailActions; + + protected $defaultAttributes = ['class' => 'history-list']; + + protected function init(): void + { + /** @var ResultSet $data */ + $data = $this->data; + $this->data = $this->getIterator($data); + $this->initializeDetailActions(); + $this->setDetailUrl(Url::fromPath('icingadb/event')); + } + + protected function getItemClass(): string + { + switch ($this->getViewMode()) { + case 'minimal': + return HistoryListItemMinimal::class; + case 'detailed': + $this->removeAttribute('class', 'default-layout'); + + return HistoryListItemDetailed::class; + default: + return HistoryListItem::class; + } + } + + protected function assemble(): void + { + $this->addAttributes(['class' => $this->getViewMode()]); + + parent::assemble(); + } +} diff --git a/library/Icingadb/Widget/ItemList/HistoryListItem.php b/library/Icingadb/Widget/ItemList/HistoryListItem.php new file mode 100644 index 0000000..c44a807 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HistoryListItem.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemCommonLayout; +use ipl\Web\Widget\StateBall; + +class HistoryListItem extends BaseHistoryListItem +{ + use ListItemCommonLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } +} diff --git a/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php b/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php new file mode 100644 index 0000000..7129d2d --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemDetailedLayout; +use ipl\Web\Widget\StateBall; + +class HistoryListItemDetailed extends BaseHistoryListItem +{ + use ListItemDetailedLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } +} diff --git a/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php b/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php new file mode 100644 index 0000000..5a7f214 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php @@ -0,0 +1,27 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemMinimalLayout; +use ipl\Web\Widget\StateBall; + +class HistoryListItemMinimal extends BaseHistoryListItem +{ + use ListItemMinimalLayout; + + protected function init(): void + { + parent::init(); + + if ($this->list->isCaptionDisabled()) { + $this->setCaptionDisabled(); + } + } + + protected function getStateBallSize(): string + { + return StateBall::SIZE_BIG; + } +} diff --git a/library/Icingadb/Widget/ItemList/HostDetailHeader.php b/library/Icingadb/Widget/ItemList/HostDetailHeader.php new file mode 100644 index 0000000..97176da --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HostDetailHeader.php @@ -0,0 +1,67 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\HostStates; +use Icinga\Module\Icingadb\Widget\StateChange; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Widget\StateBall; + +class HostDetailHeader extends HostListItemMinimal +{ + protected function getStateBallSize(): string + { + return ''; + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + if ($this->state->state_type === 'soft') { + $stateType = 'soft_state'; + $previousStateType = 'previous_soft_state'; + + if ($this->state->previous_soft_state === 0) { + $previousStateType = 'hard_state'; + } + } else { + $stateType = 'hard_state'; + $previousStateType = 'previous_hard_state'; + + if ($this->state->hard_state === $this->state->previous_hard_state) { + $previousStateType = 'previous_soft_state'; + } + } + + $state = HostStates::text($this->state->$stateType); + $previousState = HostStates::text($this->state->$previousStateType); + + $stateChange = new StateChange($state, $previousState); + if ($stateType === 'soft_state') { + $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE); + } + + if ($previousStateType === 'previous_soft_state') { + $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE); + if ($stateType === 'soft_state') { + $visual->getAttributes()->add('class', 'small-state-change'); + } + } + + $stateChange->setIcon($this->state->getIcon()); + $stateChange->setHandled($this->state->is_handled || ! $this->state->is_reachable); + + $visual->addHtml($stateChange); + } + + protected function assemble(): void + { + $attributes = $this->list->getAttributes(); + if (! in_array('minimal', $attributes->get('class')->getValue())) { + $attributes->add('class', 'minimal'); + } + + parent::assemble(); + } +} diff --git a/library/Icingadb/Widget/ItemList/HostList.php b/library/Icingadb/Widget/ItemList/HostList.php new file mode 100644 index 0000000..2be1f84 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HostList.php @@ -0,0 +1,39 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\Links; +use ipl\Web\Url; + +/** + * Host list + */ +class HostList extends StateList +{ + protected $defaultAttributes = ['class' => 'host-list']; + + protected function getItemClass(): string + { + switch ($this->getViewMode()) { + case 'minimal': + return HostListItemMinimal::class; + case 'detailed': + $this->removeAttribute('class', 'default-layout'); + + return HostListItemDetailed::class; + case 'objectHeader': + return HostDetailHeader::class; + default: + return HostListItem::class; + } + } + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setMultiselectUrl(Links::hostsDetails()); + $this->setDetailUrl(Url::fromPath('icingadb/host')); + } +} diff --git a/library/Icingadb/Widget/ItemList/HostListItem.php b/library/Icingadb/Widget/ItemList/HostListItem.php new file mode 100644 index 0000000..2eae660 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HostListItem.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemCommonLayout; +use ipl\Web\Widget\StateBall; + +class HostListItem extends BaseHostListItem +{ + use ListItemCommonLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } +} diff --git a/library/Icingadb/Widget/ItemList/HostListItemDetailed.php b/library/Icingadb/Widget/ItemList/HostListItemDetailed.php new file mode 100644 index 0000000..255bdcc --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HostListItemDetailed.php @@ -0,0 +1,108 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemDetailedLayout; +use Icinga\Module\Icingadb\Util\PerfDataSet; +use Icinga\Module\Icingadb\Widget\ItemList\CommentList; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; + +class HostListItemDetailed extends BaseHostListItem +{ + use ListItemDetailedLayout; + + /** @var int Max pie charts to be shown */ + const PIE_CHART_LIMIT = 5; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } + + protected function assembleFooter(BaseHtmlElement $footer): void + { + $statusIcons = new HtmlElement('div', Attributes::create(['class' => 'status-icons'])); + + if ($this->item->state->last_comment->host_id === $this->item->id) { + $comment = $this->item->state->last_comment; + $comment->host = $this->item; + $comment = (new CommentList([$comment])) + ->setNoSubjectLink() + ->setObjectLinkDisabled() + ->setDetailActionsDisabled(); + + $statusIcons->addHtml( + new HtmlElement( + 'div', + Attributes::create(['class' => 'comment-wrapper']), + new HtmlElement('div', Attributes::create(['class' => 'comment-popup']), $comment), + (new Icon('comments', ['class' => 'comment-icon'])) + ) + ); + } + + if ($this->item->state->is_flapping) { + $statusIcons->addHtml(new Icon( + 'random', + [ + 'title' => sprintf(t('Host "%s" is in flapping state'), $this->item->display_name), + ] + )); + } + + if (! $this->item->notifications_enabled) { + $statusIcons->addHtml(new Icon('bell-slash', ['title' => t('Notifications disabled')])); + } + + if (! $this->item->active_checks_enabled) { + $statusIcons->addHtml(new Icon('eye-slash', ['title' => t('Active checks disabled')])); + } + + $performanceData = new HtmlElement('div', Attributes::create(['class' => 'performance-data'])); + if ($this->item->state->performance_data) { + $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray(); + + $pies = []; + foreach ($pieChartData as $i => $perfdata) { + if ($perfdata->isVisualizable()) { + $pies[] = $perfdata->asInlinePie()->render(); + } + + // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT + if (count($pies) > HostListItemDetailed::PIE_CHART_LIMIT) { + break; + } + } + + $maxVisiblePies = HostListItemDetailed::PIE_CHART_LIMIT - 2; + $numOfPies = count($pies); + foreach ($pies as $i => $pie) { + if ( + // Show max. 5 elements: if there are more than 5, show 4 + `…` + $i > $maxVisiblePies && $numOfPies > HostListItemDetailed::PIE_CHART_LIMIT + ) { + $performanceData->addHtml(new HtmlElement('span', null, Text::create('…'))); + break; + } + + $performanceData->addHtml(HtmlString::create($pie)); + } + } + + if (! $statusIcons->isEmpty()) { + $footer->addHtml($statusIcons); + } + + if (! $performanceData->isEmpty()) { + $footer->addHtml($performanceData); + } + } +} diff --git a/library/Icingadb/Widget/ItemList/HostListItemMinimal.php b/library/Icingadb/Widget/ItemList/HostListItemMinimal.php new file mode 100644 index 0000000..f04b991 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/HostListItemMinimal.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemMinimalLayout; +use ipl\Web\Widget\StateBall; + +class HostListItemMinimal extends BaseHostListItem +{ + use ListItemMinimalLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_BIG; + } +} diff --git a/library/Icingadb/Widget/ItemList/NotificationList.php b/library/Icingadb/Widget/ItemList/NotificationList.php new file mode 100644 index 0000000..3a16b0b --- /dev/null +++ b/library/Icingadb/Widget/ItemList/NotificationList.php @@ -0,0 +1,55 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\CaptionDisabled; +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\LoadMore; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\ViewMode; +use ipl\Orm\ResultSet; +use ipl\Web\Common\BaseItemList; +use ipl\Web\Url; + +class NotificationList extends BaseItemList +{ + use CaptionDisabled; + use NoSubjectLink; + use ViewMode; + use LoadMore; + use DetailActions; + + protected $defaultAttributes = ['class' => 'notification-list']; + + protected function init(): void + { + /** @var ResultSet $data */ + $data = $this->data; + $this->data = $this->getIterator($data); + $this->initializeDetailActions(); + $this->setDetailUrl(Url::fromPath('icingadb/event')); + } + + protected function getItemClass(): string + { + switch ($this->getViewMode()) { + case 'minimal': + return NotificationListItemMinimal::class; + case 'detailed': + $this->removeAttribute('class', 'default-layout'); + + return NotificationListItemDetailed::class; + default: + return NotificationListItem::class; + } + } + + protected function assemble(): void + { + $this->addAttributes(['class' => $this->getViewMode()]); + + parent::assemble(); + } +} diff --git a/library/Icingadb/Widget/ItemList/NotificationListItem.php b/library/Icingadb/Widget/ItemList/NotificationListItem.php new file mode 100644 index 0000000..683762f --- /dev/null +++ b/library/Icingadb/Widget/ItemList/NotificationListItem.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemCommonLayout; +use ipl\Web\Widget\StateBall; + +class NotificationListItem extends BaseNotificationListItem +{ + use ListItemCommonLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } +} diff --git a/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php b/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php new file mode 100644 index 0000000..0a7449e --- /dev/null +++ b/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemDetailedLayout; +use ipl\Web\Widget\StateBall; + +class NotificationListItemDetailed extends BaseNotificationListItem +{ + use ListItemDetailedLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } +} diff --git a/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php b/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php new file mode 100644 index 0000000..dd6d226 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php @@ -0,0 +1,27 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemMinimalLayout; +use ipl\Web\Widget\StateBall; + +class NotificationListItemMinimal extends BaseNotificationListItem +{ + use ListItemMinimalLayout; + + protected function init(): void + { + parent::init(); + + if ($this->list->isCaptionDisabled()) { + $this->setCaptionDisabled(); + } + } + + protected function getStateBallSize(): string + { + return StateBall::SIZE_BIG; + } +} diff --git a/library/Icingadb/Widget/ItemList/PageSeparatorItem.php b/library/Icingadb/Widget/ItemList/PageSeparatorItem.php new file mode 100644 index 0000000..3e252eb --- /dev/null +++ b/library/Icingadb/Widget/ItemList/PageSeparatorItem.php @@ -0,0 +1,36 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class PageSeparatorItem extends BaseHtmlElement +{ + protected $defaultAttributes = ['class' => 'list-item page-separator']; + + /** @var int */ + protected $pageNumber; + + /** @var string */ + protected $tag = 'li'; + + public function __construct(int $pageNumber) + { + $this->pageNumber = $pageNumber; + } + + protected function assemble() + { + $this->add(Html::tag( + 'a', + [ + 'id' => 'page-' . $this->pageNumber, + 'data-icinga-no-scroll-on-focus' => true + ], + $this->pageNumber + )); + } +} diff --git a/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php b/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php new file mode 100644 index 0000000..2f0dbbd --- /dev/null +++ b/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php @@ -0,0 +1,67 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Module\Icingadb\Widget\StateChange; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Widget\StateBall; + +class ServiceDetailHeader extends ServiceListItemMinimal +{ + protected function getStateBallSize(): string + { + return ''; + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + if ($this->state->state_type === 'soft') { + $stateType = 'soft_state'; + $previousStateType = 'previous_soft_state'; + + if ($this->state->previous_soft_state === 0) { + $previousStateType = 'hard_state'; + } + } else { + $stateType = 'hard_state'; + $previousStateType = 'previous_hard_state'; + + if ($this->state->hard_state === $this->state->previous_hard_state) { + $previousStateType = 'previous_soft_state'; + } + } + + $state = ServiceStates::text($this->state->$stateType); + $previousState = ServiceStates::text($this->state->$previousStateType); + + $stateChange = new StateChange($state, $previousState); + if ($stateType === 'soft_state') { + $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE); + } + + if ($previousStateType === 'previous_soft_state') { + $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE); + if ($stateType === 'soft_state') { + $visual->getAttributes()->add('class', 'small-state-change'); + } + } + + $stateChange->setIcon($this->state->getIcon()); + $stateChange->setHandled($this->state->is_handled || ! $this->state->is_reachable); + + $visual->addHtml($stateChange); + } + + protected function assemble(): void + { + $attributes = $this->list->getAttributes(); + if (! in_array('minimal', $attributes->get('class')->getValue())) { + $attributes->add('class', 'minimal'); + } + + parent::assemble(); + } +} diff --git a/library/Icingadb/Widget/ItemList/ServiceList.php b/library/Icingadb/Widget/ItemList/ServiceList.php new file mode 100644 index 0000000..8d41a70 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/ServiceList.php @@ -0,0 +1,36 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\Links; +use ipl\Web\Url; + +class ServiceList extends StateList +{ + protected $defaultAttributes = ['class' => 'service-list']; + + protected function getItemClass(): string + { + switch ($this->getViewMode()) { + case 'minimal': + return ServiceListItemMinimal::class; + case 'detailed': + $this->removeAttribute('class', 'default-layout'); + + return ServiceListItemDetailed::class; + case 'objectHeader': + return ServiceDetailHeader::class; + default: + return ServiceListItem::class; + } + } + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setMultiselectUrl(Links::servicesDetails()); + $this->setDetailUrl(Url::fromPath('icingadb/service')); + } +} diff --git a/library/Icingadb/Widget/ItemList/ServiceListItem.php b/library/Icingadb/Widget/ItemList/ServiceListItem.php new file mode 100644 index 0000000..a974581 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/ServiceListItem.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemCommonLayout; +use ipl\Web\Widget\StateBall; + +class ServiceListItem extends BaseServiceListItem +{ + use ListItemCommonLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } +} diff --git a/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php b/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php new file mode 100644 index 0000000..1613599 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php @@ -0,0 +1,112 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemDetailedLayout; +use Icinga\Module\Icingadb\Util\PerfDataSet; +use Icinga\Module\Icingadb\Widget\ItemList\CommentList; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; + +class ServiceListItemDetailed extends BaseServiceListItem +{ + use ListItemDetailedLayout; + + /** @var int Max pie charts to be shown */ + const PIE_CHART_LIMIT = 5; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } + + protected function assembleFooter(BaseHtmlElement $footer): void + { + $statusIcons = new HtmlElement('div', Attributes::create(['class' => 'status-icons'])); + + if ($this->item->state->last_comment->service_id === $this->item->id) { + $comment = $this->item->state->last_comment; + $comment->service = $this->item; + $comment = (new CommentList([$comment])) + ->setNoSubjectLink() + ->setObjectLinkDisabled() + ->setDetailActionsDisabled(); + + $statusIcons->addHtml( + new HtmlElement( + 'div', + Attributes::create(['class' => 'comment-wrapper']), + new HtmlElement('div', Attributes::create(['class' => 'comment-popup']), $comment), + (new Icon('comments', ['class' => 'comment-icon'])) + ) + ); + } + + if ($this->item->state->is_flapping) { + $statusIcons->addHtml(new Icon( + 'random', + [ + 'title' => sprintf( + t('Service "%s" on "%s" is in flapping state'), + $this->item->display_name, + $this->item->host->display_name + ), + ] + )); + } + + if (! $this->item->notifications_enabled) { + $statusIcons->addHtml(new Icon('bell-slash', ['title' => t('Notifications disabled')])); + } + + if (! $this->item->active_checks_enabled) { + $statusIcons->addHtml(new Icon('eye-slash', ['title' => t('Active checks disabled')])); + } + + $performanceData = new HtmlElement('div', Attributes::create(['class' => 'performance-data'])); + if ($this->item->state->performance_data) { + $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray(); + + $pies = []; + foreach ($pieChartData as $i => $perfdata) { + if ($perfdata->isVisualizable()) { + $pies[] = $perfdata->asInlinePie()->render(); + } + + // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT + if (count($pies) > ServiceListItemDetailed::PIE_CHART_LIMIT) { + break; + } + } + + $maxVisiblePies = ServiceListItemDetailed::PIE_CHART_LIMIT - 2; + $numOfPies = count($pies); + foreach ($pies as $i => $pie) { + if ( + // Show max. 5 elements: if there are more than 5, show 4 + `…` + $i > $maxVisiblePies && $numOfPies > ServiceListItemDetailed::PIE_CHART_LIMIT + ) { + $performanceData->addHtml(new HtmlElement('span', null, Text::create('…'))); + break; + } + + $performanceData->addHtml(HtmlString::create($pie)); + } + } + + if (! $statusIcons->isEmpty()) { + $footer->addHtml($statusIcons); + } + + if (! $performanceData->isEmpty()) { + $footer->addHtml($performanceData); + } + } +} diff --git a/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php b/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php new file mode 100644 index 0000000..e7a1bc6 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php @@ -0,0 +1,18 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\ListItemMinimalLayout; +use ipl\Web\Widget\StateBall; + +class ServiceListItemMinimal extends BaseServiceListItem +{ + use ListItemMinimalLayout; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_BIG; + } +} diff --git a/library/Icingadb/Widget/ItemList/StateList.php b/library/Icingadb/Widget/ItemList/StateList.php new file mode 100644 index 0000000..1e6dcb9 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/StateList.php @@ -0,0 +1,60 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\NoSubjectLink; +use Icinga\Module\Icingadb\Common\ViewMode; +use Icinga\Module\Icingadb\Redis\VolatileStateResults; +use Icinga\Module\Icingadb\Widget\Notice; +use ipl\Html\HtmlDocument; +use ipl\Web\Common\BaseItemList; + +abstract class StateList extends BaseItemList +{ + use ViewMode; + use NoSubjectLink; + use DetailActions; + + /** @var bool Whether the list contains at least one item with an icon_image */ + protected $hasIconImages = false; + + /** + * 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 + * + * @return $this + */ + public function setHasIconImages(bool $hasIconImages): self + { + $this->hasIconImages = $hasIconImages; + + return $this; + } + + protected function assemble(): void + { + $this->addAttributes(['class' => $this->getViewMode()]); + + parent::assemble(); + + if ($this->data instanceof VolatileStateResults && $this->data->isRedisUnavailable()) { + $this->prependWrapper((new HtmlDocument())->addHtml(new Notice( + t('Icinga Redis is currently unavailable. The shown information might be outdated.') + ))); + } + } +} diff --git a/library/Icingadb/Widget/ItemList/StateListItem.php b/library/Icingadb/Widget/ItemList/StateListItem.php new file mode 100644 index 0000000..d0b3363 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/StateListItem.php @@ -0,0 +1,140 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemList; + +use Icinga\Module\Icingadb\Common\Icons; +use Icinga\Module\Icingadb\Model\State; +use Icinga\Module\Icingadb\Util\PluginOutput; +use Icinga\Module\Icingadb\Widget\CheckAttempt; +use Icinga\Module\Icingadb\Widget\IconImage; +use Icinga\Module\Icingadb\Widget\PluginOutputContainer; +use ipl\Html\HtmlElement; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\TimeSince; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; + +/** + * Host or service item of a host or service list. Represents one database row. + */ +abstract class StateListItem extends BaseListItem +{ + /** @var StateList The list where the item is part of */ + protected $list; + + /** @var State The state of the item */ + protected $state; + + protected function init(): void + { + $this->state = $this->item->state; + + if (isset($this->item->icon_image->icon_image)) { + $this->list->setHasIconImages(true); + } + } + + abstract protected function createSubject(); + + abstract protected function getStateBallSize(): string; + + /** + * @return ?BaseHtmlElement + */ + protected function createIconImage(): ?BaseHtmlElement + { + if (! $this->list->hasIconImages()) { + return null; + } + + $iconImage = HtmlElement::create('div', [ + 'class' => 'icon-image', + ]); + + $this->assembleIconImage($iconImage); + + return $iconImage; + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + if ($this->state->soft_state === null && $this->state->output === null) { + $caption->addHtml(Text::create(t('Waiting for Icinga DB to synchronize the state.'))); + } else { + if (empty($this->state->output)) { + $pluginOutput = new EmptyState(t('Output unavailable.')); + } else { + $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->item)); + } + + $caption->addHtml($pluginOutput); + } + } + + protected function assembleIconImage(BaseHtmlElement $iconImage): void + { + if (isset($this->item->icon_image->icon_image)) { + $iconImage->addHtml(new IconImage($this->item->icon_image->icon_image, $this->item->icon_image_alt)); + } else { + $iconImage->addAttributes(['class' => 'placeholder']); + } + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml(Html::sprintf( + t('%s is %s', '<hostname> is <state-text>'), + $this->createSubject(), + Html::tag('span', ['class' => 'state-text'], $this->state->getStateTextTranslated()) + )); + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $stateBall = new StateBall($this->state->getStateText(), $this->getStateBallSize()); + $stateBall->add($this->state->getIcon()); + if ($this->state->is_handled || ! $this->state->is_reachable) { + $stateBall->getAttributes()->add('class', 'handled'); + } + + $visual->addHtml($stateBall); + if ($this->state->state_type === 'soft') { + $visual->addHtml( + new CheckAttempt((int) $this->state->check_attempt, (int) $this->item->max_check_attempts) + ); + } + } + + protected function createTimestamp(): ?BaseHtmlElement + { + $since = null; + if ($this->state->is_overdue) { + $since = new TimeSince($this->state->next_update->getTimestamp()); + $since->prepend(t('Overdue') . ' '); + $since->prependHtml(new Icon(Icons::WARNING)); + } elseif ($this->state->last_state_change !== null && $this->state->last_state_change->getTimestamp() > 0) { + $since = new TimeSince($this->state->last_state_change->getTimestamp()); + } + + return $since; + } + + protected function assemble(): void + { + if ($this->state->is_overdue) { + $this->addAttributes(['class' => 'overdue']); + } + + $this->add([ + $this->createVisual(), + $this->createIconImage(), + $this->createMain() + ]); + } +} diff --git a/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php b/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php new file mode 100644 index 0000000..c56a1f8 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php @@ -0,0 +1,60 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\Hostgroup; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTableRowItem; +use ipl\Web\Widget\Link; + +/** + * Hostgroup item of a hostgroup list. Represents one database row. + * + * @property Hostgroup $item + * @property HostgroupTable $table + */ +abstract class BaseHostGroupItem extends BaseTableRowItem +{ + use Translation; + + protected function init(): void + { + if (isset($this->table)) { + $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); + } + } + + protected function createSubject(): BaseHtmlElement + { + return isset($this->table) + ? new Link( + $this->item->display_name, + Links::hostgroup($this->item), + [ + 'class' => 'subject', + 'title' => sprintf( + $this->translate('List all hosts in the group "%s"'), + $this->item->display_name + ) + ] + ) + : new HtmlElement( + 'span', + Attributes::create(['class' => 'subject']), + Text::create($this->item->display_name) + ); + } + + protected function createCaption(): BaseHtmlElement + { + return new HtmlElement('span', null, Text::create($this->item->name)); + } +} diff --git a/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php b/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php new file mode 100644 index 0000000..7bee532 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php @@ -0,0 +1,60 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\Servicegroup; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTableRowItem; +use ipl\Web\Widget\Link; + +/** + * Servicegroup item of a servicegroup list. Represents one database row. + * + * @property Servicegroup $item + * @property ServicegroupTable $table + */ +abstract class BaseServiceGroupItem extends BaseTableRowItem +{ + use Translation; + + protected function init(): void + { + if (isset($this->table)) { + $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); + } + } + + protected function createSubject(): BaseHtmlElement + { + return isset($this->table) + ? new Link( + $this->item->display_name, + Links::servicegroup($this->item), + [ + 'class' => 'subject', + 'title' => sprintf( + $this->translate('List all services in the group "%s"'), + $this->item->display_name + ) + ] + ) + : new HtmlElement( + 'span', + Attributes::create(['class' => 'subject']), + Text::create($this->item->display_name) + ); + } + + protected function createCaption(): BaseHtmlElement + { + return new HtmlElement('span', null, Text::create($this->item->name)); + } +} diff --git a/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php b/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php new file mode 100644 index 0000000..642d6b3 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php @@ -0,0 +1,107 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Orm\Model; + +/** @todo Figure out what this might (should) have in common with the new BaseTableRowItem implementation */ +abstract class BaseStateRowItem extends BaseHtmlElement +{ + protected $defaultAttributes = ['class' => 'row-item']; + + /** @var Model */ + protected $item; + + /** @var StateItemTable */ + protected $list; + + protected $tag = 'tr'; + + /** + * Create a new row item + * + * @param Model $item + * @param StateItemTable $list + */ + public function __construct(Model $item, StateItemTable $list) + { + $this->item = $item; + $this->list = $list; + + $this->init(); + } + + /** + * Initialize the row item + * + * If you want to adjust the row item after construction, override this method. + */ + protected function init() + { + } + + abstract protected function assembleVisual(BaseHtmlElement $visual); + + abstract protected function assembleCell(BaseHtmlElement $cell, string $path, $value); + + protected function createVisual(): BaseHtmlElement + { + $visual = new HtmlElement('td', Attributes::create(['class' => 'visual'])); + + $this->assembleVisual($visual); + + return $visual; + } + + protected function assemble() + { + $this->addHtml($this->createVisual()); + + foreach ($this->list->getColumns() as $columnPath => $_) { + $steps = explode('.', $columnPath); + if ($steps[0] === $this->item->getTableName()) { + array_shift($steps); + $columnPath = implode('.', $steps); + } + + $column = null; + $subject = $this->item; + foreach ($steps as $i => $step) { + if (isset($subject->$step)) { + if ($subject->$step instanceof Model) { + $subject = $subject->$step; + } else { + $column = $step; + } + } else { + $columnCandidate = implode('.', array_slice($steps, $i)); + if (isset($subject->$columnCandidate)) { + $column = $columnCandidate; + } else { + break; + } + } + } + + $value = null; + if ($column !== null) { + $value = $subject->$column; + if (is_array($value)) { + $value = empty($value) ? null : implode(',', $value); + } + } + + $cell = new HtmlElement('td'); + if ($value !== null) { + $this->assembleCell($cell, $columnPath, $value); + } + + $this->addHtml($cell); + } + } +} diff --git a/library/Icingadb/Widget/ItemTable/GridCellLayout.php b/library/Icingadb/Widget/ItemTable/GridCellLayout.php new file mode 100644 index 0000000..95b1a0a --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/GridCellLayout.php @@ -0,0 +1,39 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use ipl\Html\BaseHtmlElement; +use ipl\Web\Widget\Link; + +trait GridCellLayout +{ + /** + * Creates a state badge for the Host / Service group with the highest severity that an object in the group has, + * along with the count of the objects with this severity belonging to the corresponding group. + * + * @return Link + */ + abstract public function createGroupBadge(): Link; + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $visual->add($this->createGroupBadge()); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml( + $this->createSubject(), + $this->createCaption() + ); + } + + protected function assemble(): void + { + $this->add([ + $this->createTitle() + ]); + } +} diff --git a/library/Icingadb/Widget/ItemTable/HostItemTable.php b/library/Icingadb/Widget/ItemTable/HostItemTable.php new file mode 100644 index 0000000..e303746 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/HostItemTable.php @@ -0,0 +1,31 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\Links; +use ipl\Web\Url; + +class HostItemTable extends StateItemTable +{ + use DetailActions; + + protected function init() + { + $this->initializeDetailActions(); + $this->setMultiselectUrl(Links::hostsDetails()); + $this->setDetailUrl(Url::fromPath('icingadb/host')); + } + + protected function getItemClass(): string + { + return HostRowItem::class; + } + + protected function getVisualColumn(): string + { + return 'host.state.severity'; + } +} diff --git a/library/Icingadb/Widget/ItemTable/HostRowItem.php b/library/Icingadb/Widget/ItemTable/HostRowItem.php new file mode 100644 index 0000000..cff70dd --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/HostRowItem.php @@ -0,0 +1,51 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\Host; +use ipl\Html\BaseHtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Widget\Link; + +class HostRowItem extends StateRowItem +{ + /** @var HostItemTable */ + protected $list; + + /** @var Host */ + protected $item; + + protected function init() + { + parent::init(); + + $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)) + ->addMultiselectFilterAttribute($this, Filter::equal('host.name', $this->item->name)); + } + + protected function assembleCell(BaseHtmlElement $cell, string $path, $value) + { + switch ($path) { + case 'name': + case 'display_name': + $cell->addHtml(new Link($this->item->$path, Links::host($this->item), [ + 'class' => 'subject', + 'title' => $this->item->$path + ])); + break; + case 'service.name': + case 'service.display_name': + $column = substr($path, 8); + $cell->addHtml(new Link( + $this->item->service->$column, + Links::service($this->item->service, $this->item) + )); + break; + default: + parent::assembleCell($cell, $path, $value); + } + } +} diff --git a/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php b/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php new file mode 100644 index 0000000..5396747 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php @@ -0,0 +1,114 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use ipl\Stdlib\Filter; +use ipl\Web\Url; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBadge; + +class HostgroupGridCell extends BaseHostGroupItem +{ + use GridCellLayout; + + protected $defaultAttributes = ['class' => ['group-grid-cell', 'hostgroup-grid-cell']]; + + protected function createGroupBadge(): Link + { + $url = Url::fromPath('icingadb/hosts'); + $urlFilter = Filter::all(Filter::equal('hostgroup.name', $this->item->name)); + + if ($this->item->hosts_down_unhandled > 0) { + $urlFilter->add(Filter::equal('host.state.soft_state', 1)) + ->add(Filter::equal('host.state.is_handled', 'n')) + ->add(Filter::equal('host.state.is_reachable', 'y')); + + return new Link( + new StateBadge($this->item->hosts_down_unhandled, 'down'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d host that is currently in DOWN state in host group "%s"', + 'List %d hosts which are currently in DOWN state in host group "%s"', + $this->item->hosts_down_unhandled + ), + $this->item->hosts_down_unhandled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->hosts_down_handled > 0) { + $urlFilter->add(Filter::equal('host.state.soft_state', 1)) + ->add(Filter::any( + Filter::equal('host.state.is_handled', 'y'), + Filter::equal('host.state.is_reachable', 'n') + )); + + return new Link( + new StateBadge($this->item->hosts_down_handled, 'down', true), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d host that is currently in DOWN (Acknowledged) state in host group "%s"', + 'List %d hosts which are currently in DOWN (Acknowledged) state in host group "%s"', + $this->item->hosts_down_handled + ), + $this->item->hosts_down_handled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->hosts_pending > 0) { + $urlFilter->add(Filter::equal('host.state.soft_state', 99)); + + return new Link( + new StateBadge($this->item->hosts_pending, 'pending'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d host that is currently in PENDING state in host group "%s"', + 'List %d hosts which are currently in PENDING state in host group "%s"', + $this->item->hosts_pending + ), + $this->item->hosts_pending, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->hosts_up > 0) { + $urlFilter->add(Filter::equal('host.state.soft_state', 0)); + + return new Link( + new StateBadge($this->item->hosts_up, 'up'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d host that is currently in UP state in host group "%s"', + 'List %d hosts which are currently in UP state in host group "%s"', + $this->item->hosts_up + ), + $this->item->hosts_up, + $this->item->display_name + ) + ] + ); + } + + return new Link( + new StateBadge(0, 'none'), + $url, + [ + 'title' => sprintf( + $this->translate('There are no hosts in host group "%s"'), + $this->item->display_name + ) + ] + ); + } +} diff --git a/library/Icingadb/Widget/ItemTable/HostgroupTable.php b/library/Icingadb/Widget/ItemTable/HostgroupTable.php new file mode 100644 index 0000000..6b40f76 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/HostgroupTable.php @@ -0,0 +1,38 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\ViewMode; +use ipl\Web\Common\BaseItemTable; +use ipl\Web\Url; + +class HostgroupTable extends BaseItemTable +{ + use DetailActions; + use ViewMode; + + protected $defaultAttributes = ['class' => 'hostgroup-table']; + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setDetailUrl(Url::fromPath('icingadb/hostgroup')); + } + + protected function getLayout(): string + { + return $this->getViewMode() === 'grid' + ? 'group-grid' + : parent::getLayout(); + } + + protected function getItemClass(): string + { + return $this->getViewMode() === 'grid' + ? HostgroupGridCell::class + : HostgroupTableRow::class; + } +} diff --git a/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php b/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php new file mode 100644 index 0000000..6aa61c2 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php @@ -0,0 +1,55 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Model\Hostgroup; +use Icinga\Module\Icingadb\Widget\Detail\HostStatistics; +use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics; +use ipl\Html\BaseHtmlElement; +use ipl\Stdlib\Filter; + +/** + * Hostgroup table row of a hostgroup table. Represents one database row. + * + * @property Hostgroup $item + * @property HostgroupTable $table + */ +class HostgroupTableRow extends BaseHostGroupItem +{ + use TableRowLayout; + + protected $defaultAttributes = ['class' => 'hostgroup-table-row']; + + /** + * Create Host and service statistics columns + * + * @return BaseHtmlElement[] + */ + protected function createStatistics(): array + { + $hostStats = new HostStatistics($this->item); + + $hostStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name)); + if (isset($this->table) && $this->table->hasBaseFilter()) { + $hostStats->setBaseFilter( + Filter::all($hostStats->getBaseFilter(), $this->table->getBaseFilter()) + ); + } + + $serviceStats = new ServiceStatistics($this->item); + + $serviceStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name)); + if (isset($this->table) && $this->table->hasBaseFilter()) { + $serviceStats->setBaseFilter( + Filter::all($serviceStats->getBaseFilter(), $this->table->getBaseFilter()) + ); + } + + return [ + $this->createColumn($hostStats), + $this->createColumn($serviceStats) + ]; + } +} diff --git a/library/Icingadb/Widget/ItemTable/ServiceItemTable.php b/library/Icingadb/Widget/ItemTable/ServiceItemTable.php new file mode 100644 index 0000000..60872d8 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/ServiceItemTable.php @@ -0,0 +1,31 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\Links; +use ipl\Web\Url; + +class ServiceItemTable extends StateItemTable +{ + use DetailActions; + + protected function init() + { + $this->initializeDetailActions(); + $this->setMultiselectUrl(Links::servicesDetails()); + $this->setDetailUrl(Url::fromPath('icingadb/service')); + } + + protected function getItemClass(): string + { + return ServiceRowItem::class; + } + + protected function getVisualColumn(): string + { + return 'service.state.severity'; + } +} diff --git a/library/Icingadb/Widget/ItemTable/ServiceRowItem.php b/library/Icingadb/Widget/ItemTable/ServiceRowItem.php new file mode 100644 index 0000000..0fb95d0 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/ServiceRowItem.php @@ -0,0 +1,64 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Html\BaseHtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Widget\Link; + +class ServiceRowItem extends StateRowItem +{ + /** @var ServiceItemTable */ + protected $list; + + /** @var Service */ + protected $item; + + protected function init() + { + parent::init(); + + $this->list->addMultiselectFilterAttribute( + $this, + Filter::all( + Filter::equal('service.name', $this->item->name), + Filter::equal('host.name', $this->item->host->name) + ) + ); + $this->list->addDetailFilterAttribute( + $this, + Filter::all( + Filter::equal('name', $this->item->name), + Filter::equal('host.name', $this->item->host->name) + ) + ); + } + + protected function assembleCell(BaseHtmlElement $cell, string $path, $value) + { + switch ($path) { + case 'name': + case 'display_name': + $cell->addHtml(new Link( + $this->item->$path, + Links::service($this->item, $this->item->host), + [ + 'class' => 'subject', + 'title' => $this->item->$path + ] + )); + break; + case 'host.name': + case 'host.display_name': + $column = substr($path, 5); + $cell->addHtml(new Link($this->item->host->$column, Links::host($this->item->host))); + break; + default: + parent::assembleCell($cell, $path, $value); + } + } +} diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php b/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php new file mode 100644 index 0000000..16e50e1 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php @@ -0,0 +1,204 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use ipl\Stdlib\Filter; +use ipl\Web\Url; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBadge; + +class ServicegroupGridCell extends BaseServiceGroupItem +{ + use GridCellLayout; + + protected $defaultAttributes = ['class' => ['group-grid-cell', 'servicegroup-grid-cell']]; + + protected function createGroupBadge(): Link + { + $url = Url::fromPath('icingadb/services/grid'); + $urlFilter = Filter::all(Filter::equal('servicegroup.name', $this->item->name)); + + if ($this->item->services_critical_unhandled > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 2)) + ->add(Filter::equal('service.state.is_handled', 'n')) + ->add(Filter::equal('service.state.is_reachable', 'y')); + + return new Link( + new StateBadge($this->item->services_critical_unhandled, 'critical'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in CRITICAL state in service group "%s"', + 'List %d services which are currently in CRITICAL state in service group "%s"', + $this->item->services_critical_unhandled + ), + $this->item->services_critical_unhandled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->services_critical_handled > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 2)) + ->add(Filter::any( + Filter::equal('service.state.is_handled', 'y'), + Filter::equal('service.state.is_reachable', 'n') + )); + + return new Link( + new StateBadge($this->item->services_critical_handled, 'critical', true), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in CRITICAL (Acknowledged) state in service group' + . ' "%s"', + 'List %d services which are currently in CRITICAL (Acknowledged) state in service group' + . ' "%s"', + $this->item->services_critical_handled + ), + $this->item->services_critical_handled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->services_warning_unhandled > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 1)) + ->add(Filter::equal('service.state.is_handled', 'n')) + ->add(Filter::equal('service.state.is_reachable', 'y')); + + return new Link( + new StateBadge($this->item->services_warning_unhandled, 'warning'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in WARNING state in service group "%s"', + 'List %d services which are currently in WARNING state in service group "%s"', + $this->item->services_warning_unhandled + ), + $this->item->services_warning_unhandled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->services_warning_handled > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 1)) + ->add(Filter::any( + Filter::equal('service.state.is_handled', 'y'), + Filter::equal('service.state.is_reachable', 'n') + )); + + return new Link( + new StateBadge($this->item->services_warning_handled, 'warning', true), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in WARNING (Acknowledged) state in service group' + . ' "%s"', + 'List %d services which are currently in WARNING (Acknowledged) state in service group' + . ' "%s"', + $this->item->services_warning_handled + ), + $this->item->services_warning_handled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->services_unknown_unhandled > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 3)) + ->add(Filter::equal('service.state.is_handled', 'n')) + ->add(Filter::equal('service.state.is_reachable', 'y')); + + return new Link( + new StateBadge($this->item->services_unknown_unhandled, 'unknown'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in UNKNOWN state in service group "%s"', + 'List %d services which are currently in UNKNOWN state in service group "%s"', + $this->item->services_unknown_unhandled + ), + $this->item->services_unknown_unhandled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->services_unknown_handled > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 3)) + ->add(Filter::any( + Filter::equal('service.state.is_handled', 'y'), + Filter::equal('service.state.is_reachable', 'n') + )); + + return new Link( + new StateBadge($this->item->services_unknown_handled, 'unknown', true), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in UNKNOWN (Acknowledged) state in service group' + . ' "%s"', + 'List %d services which are currently in UNKNOWN (Acknowledged) state in service group' + . ' "%s"', + $this->item->services_unknown_handled + ), + $this->item->services_unknown_handled, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->services_pending > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 99)); + + return new Link( + new StateBadge($this->item->services_pending, 'pending'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in PENDING state in service group "%s"', + 'List %d services which are currently in PENDING state in service group "%s"', + $this->item->services_pending + ), + $this->item->services_pending, + $this->item->display_name + ) + ] + ); + } elseif ($this->item->services_ok > 0) { + $urlFilter->add(Filter::equal('service.state.soft_state', 0)); + + return new Link( + new StateBadge($this->item->services_ok, 'ok'), + $url->setFilter($urlFilter), + [ + 'title' => sprintf( + $this->translatePlural( + 'List %d service that is currently in OK state in service group "%s"', + 'List %d services which are currently in OK state in service group "%s"', + $this->item->services_ok + ), + $this->item->services_ok, + $this->item->display_name + ) + ] + ); + } + + return new Link( + new StateBadge(0, 'none'), + $url, + [ + 'title' => sprintf( + $this->translate('There are no services in service group "%s"'), + $this->item->display_name + ) + ] + ); + } +} diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupTable.php b/library/Icingadb/Widget/ItemTable/ServicegroupTable.php new file mode 100644 index 0000000..2378a77 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/ServicegroupTable.php @@ -0,0 +1,38 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\DetailActions; +use Icinga\Module\Icingadb\Common\ViewMode; +use ipl\Web\Common\BaseItemTable; +use ipl\Web\Url; + +class ServicegroupTable extends BaseItemTable +{ + use DetailActions; + use ViewMode; + + protected $defaultAttributes = ['class' => 'servicegroup-table']; + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setDetailUrl(Url::fromPath('icingadb/servicegroup')); + } + + protected function getLayout(): string + { + return $this->getViewMode() === 'grid' + ? 'group-grid' + : parent::getLayout(); + } + + protected function getItemClass(): string + { + return $this->getViewMode() === 'grid' + ? ServicegroupGridCell::class + : ServicegroupTableRow::class; + } +} diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php b/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php new file mode 100644 index 0000000..3dea4c1 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php @@ -0,0 +1,42 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Model\Servicegroup; +use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics; +use ipl\Html\BaseHtmlElement; +use ipl\Stdlib\Filter; + +/** + * Servicegroup item of a servicegroup list. Represents one database row. + * + * @property Servicegroup $item + * @property ServicegroupTable $table + */ +class ServicegroupTableRow extends BaseServiceGroupItem +{ + use TableRowLayout; + + protected $defaultAttributes = ['class' => 'servicegroup-table-row']; + + /** + * Create Service statistics cell + * + * @return BaseHtmlElement[] + */ + protected function createStatistics(): array + { + $serviceStats = new ServiceStatistics($this->item); + + $serviceStats->setBaseFilter(Filter::equal('servicegroup.name', $this->item->name)); + if (isset($this->table) && $this->table->hasBaseFilter()) { + $serviceStats->setBaseFilter( + Filter::all($serviceStats->getBaseFilter(), $this->table->getBaseFilter()) + ); + } + + return [$this->createColumn($serviceStats)]; + } +} diff --git a/library/Icingadb/Widget/ItemTable/StateItemTable.php b/library/Icingadb/Widget/ItemTable/StateItemTable.php new file mode 100644 index 0000000..f392322 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/StateItemTable.php @@ -0,0 +1,216 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Form; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Query; +use ipl\Web\Control\SortControl; +use ipl\Web\Widget\EmptyStateBar; +use ipl\Web\Widget\Icon; + +/** @todo Figure out what this might (should) have in common with the new BaseItemTable implementation */ +abstract class StateItemTable extends BaseHtmlElement +{ + protected $baseAttributes = [ + 'class' => 'state-item-table' + ]; + + /** @var array<string, string> The columns to render */ + protected $columns; + + /** @var iterable The datasource */ + protected $data; + + /** @var string The sort rules */ + protected $sort; + + protected $tag = 'table'; + + /** + * Create a new item table + * + * @param iterable $data Datasource of the table + * @param array<string, string> $columns The columns to render, keys are labels + */ + public function __construct(iterable $data, array $columns) + { + $this->data = $data; + $this->columns = array_flip($columns); + + $this->addAttributes($this->baseAttributes); + + $this->init(); + } + + /** + * Initialize the item table + * + * If you want to adjust the item table after construction, override this method. + */ + protected function init() + { + } + + /** + * Get the columns being rendered + * + * @return array<string, string> + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Set sort rules (as returned by {@see SortControl::getSort()}) + * + * @param ?string $sort + * + * @return $this + */ + public function setSort(?string $sort): self + { + $this->sort = $sort; + + return $this; + } + + abstract protected function getItemClass(): string; + + abstract protected function getVisualColumn(): string; + + protected function getVisualLabel() + { + return new Icon('heartbeat', ['title' => t('Severity')]); + } + + protected function assembleColumnHeader(BaseHtmlElement $header, string $name, $label): void + { + $sortRules = []; + if ($this->sort !== null) { + $sortRules = SortUtil::createOrderBy($this->sort); + } + + $active = false; + $sortDirection = null; + foreach ($sortRules as $rule) { + if ($rule[0] === $name) { + $sortDirection = $rule[1]; + $active = true; + break; + } + } + + if ($sortDirection === 'desc') { + $value = "$name asc"; + } else { + $value = "$name desc"; + } + + $icon = 'sort'; + if ($active) { + $icon = $sortDirection === 'desc' ? 'sort-up' : 'sort-down'; + } + + $form = new Form(); + $form->setAttribute('method', 'GET'); + + $button = $form->createElement('button', 'sort', [ + 'value' => $value, + 'type' => 'submit', + 'title' => is_string($label) ? $label : null, + 'class' => $active ? 'active' : null + ]); + $button->addHtml( + Html::tag( + 'span', + null, + // With to have the height sized the same as the others + $label ?? HtmlString::create(' ') + ), + new Icon($icon) + ); + $form->addElement($button); + + $header->add($form); + + switch (true) { + case substr($name, -7) === '.output': + case substr($name, -12) === '.long_output': + $header->getAttributes()->add('class', 'has-plugin-output'); + break; + case substr($name, -22) === '.icon_image.icon_image': + $header->getAttributes()->add('class', 'has-icon-images'); + break; + case substr($name, -17) === '.performance_data': + case substr($name, -28) === '.normalized_performance_data': + $header->getAttributes()->add('class', 'has-performance-data'); + break; + } + } + + protected function assemble() + { + $itemClass = $this->getItemClass(); + + $headerRow = new HtmlElement('tr'); + + $visualCell = new HtmlElement('th', Attributes::create(['class' => 'has-visual'])); + $this->assembleColumnHeader($visualCell, $this->getVisualColumn(), $this->getVisualLabel()); + $headerRow->addHtml($visualCell); + + foreach ($this->columns as $name => $label) { + $headerCell = new HtmlElement('th'); + $this->assembleColumnHeader($headerCell, $name, is_int($label) ? $name : $label); + $headerRow->addHtml($headerCell); + } + + $this->addHtml(new HtmlElement('thead', null, $headerRow)); + + $body = new HtmlElement('tbody', Attributes::create(['data-base-target' => '_next'])); + foreach ($this->data as $item) { + $body->addHtml(new $itemClass($item, $this)); + } + + if ($body->isEmpty()) { + $body->addHtml(new HtmlElement( + 'tr', + null, + new HtmlElement( + 'td', + Attributes::create(['colspan' => count($this->columns)]), + new EmptyStateBar(t('No items found.')) + ) + )); + } + + $this->addHtml($body); + } + + /** + * Enrich the given list of column names with appropriate labels + * + * @param Query $query + * @param array $columns + * + * @return array + */ + public static function applyColumnMetaData(Query $query, array $columns): array + { + $newColumns = []; + foreach ($columns as $columnPath) { + $label = $query->getResolver()->getColumnDefinition($columnPath)->getLabel(); + $newColumns[$label ?? $columnPath] = $columnPath; + } + + return $newColumns; + } +} diff --git a/library/Icingadb/Widget/ItemTable/StateRowItem.php b/library/Icingadb/Widget/ItemTable/StateRowItem.php new file mode 100644 index 0000000..f62286b --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/StateRowItem.php @@ -0,0 +1,124 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\HostStates; +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Util\PerfDataSet; +use Icinga\Module\Icingadb\Util\PluginOutput; +use Icinga\Module\Icingadb\Widget\CheckAttempt; +use Icinga\Module\Icingadb\Widget\IconImage; +use Icinga\Module\Icingadb\Widget\PluginOutputContainer; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; +use ipl\Web\Widget\TimeSince; +use ipl\Web\Widget\TimeUntil; + +abstract class StateRowItem extends BaseStateRowItem +{ + /** @var StateItemTable */ + protected $list; + + protected function assembleVisual(BaseHtmlElement $visual) + { + $stateBall = new StateBall($this->item->state->getStateText(), StateBall::SIZE_LARGE); + $stateBall->add($this->item->state->getIcon()); + + if ($this->item->state->is_handled) { + $stateBall->getAttributes()->add('class', 'handled'); + } + + $visual->addHtml($stateBall); + if ($this->item->state->state_type === 'soft') { + $visual->addHtml(new CheckAttempt( + (int) $this->item->state->check_attempt, + (int) $this->item->max_check_attempts + )); + } + } + + protected function assembleCell(BaseHtmlElement $cell, string $path, $value) + { + switch (true) { + case $path === 'state.output': + case $path === 'state.long_output': + if (empty($value)) { + $pluginOutput = new EmptyState(t('Output unavailable.')); + } else { + $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->item)); + } + + $cell->addHtml($pluginOutput) + ->getAttributes() + ->add('class', 'has-plugin-output'); + break; + case $path === 'state.soft_state': + case $path === 'state.hard_state': + case $path === 'state.previous_soft_state': + case $path === 'state.previous_hard_state': + $stateType = substr($path, 6); + if ($this->item instanceof Host) { + $stateName = HostStates::translated($this->item->state->$stateType); + } else { + $stateName = ServiceStates::translated($this->item->state->$stateType); + } + + $cell->addHtml(Text::create($stateName)); + break; + case $path === 'state.last_update': + case $path === 'state.last_state_change': + $column = substr($path, 6); + $cell->addHtml(new TimeSince($this->item->state->$column->getTimestamp())); + break; + case $path === 'state.next_check': + case $path === 'state.next_update': + $column = substr($path, 6); + $cell->addHtml(new TimeUntil($this->item->state->$column->getTimestamp())); + break; + case $path === 'state.performance_data': + case $path === 'state.normalized_performance_data': + $perfdataContainer = new HtmlElement('div', Attributes::create(['class' => 'performance-data'])); + + $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray(); + foreach ($pieChartData as $perfdata) { + if ($perfdata->isVisualizable()) { + $perfdataContainer->addHtml(new HtmlString($perfdata->asInlinePie()->render())); + } + } + + $cell->addHtml($perfdataContainer) + ->getAttributes() + ->add('class', 'has-performance-data'); + break; + case $path === 'is_volatile': + case $path === 'host.is_volatile': + case substr($path, -8) == '_enabled': + case (bool) preg_match('/state\.(is_|in_)/', $path): + if ($value) { + $cell->addHtml(new Icon('check')); + } + + break; + case $path === 'icon_image.icon_image': + $cell->addHtml(new IconImage($value, $this->item->icon_image_alt)) + ->getAttributes() + ->add('class', 'has-icon-images'); + break; + default: + if (preg_match('/(^id|_id|.id|_checksum|_bin)$/', $path)) { + $value = bin2hex($value); + } + + $cell->addHtml(Text::create($value)); + } + } +} diff --git a/library/Icingadb/Widget/ItemTable/TableRowLayout.php b/library/Icingadb/Widget/ItemTable/TableRowLayout.php new file mode 100644 index 0000000..b9ce022 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/TableRowLayout.php @@ -0,0 +1,26 @@ +<?php + +/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; + +trait TableRowLayout +{ + protected function assembleColumns(HtmlDocument $columns): void + { + foreach ($this->createStatistics() as $objectStatistic) { + $columns->addHtml($objectStatistic); + } + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml( + $this->createSubject(), + $this->createCaption() + ); + } +} diff --git a/library/Icingadb/Widget/ItemTable/UserTable.php b/library/Icingadb/Widget/ItemTable/UserTable.php new file mode 100644 index 0000000..432817b --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/UserTable.php @@ -0,0 +1,27 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\DetailActions; +use ipl\Web\Common\BaseItemTable; +use ipl\Web\Url; + +class UserTable extends BaseItemTable +{ + use DetailActions; + + protected $defaultAttributes = ['class' => 'user-table']; + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setDetailUrl(Url::fromPath('icingadb/user')); + } + + protected function getItemClass(): string + { + return UserTableRow::class; + } +} diff --git a/library/Icingadb/Widget/ItemTable/UserTableRow.php b/library/Icingadb/Widget/ItemTable/UserTableRow.php new file mode 100644 index 0000000..c10851e --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/UserTableRow.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\User; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTableRowItem; +use ipl\Web\Widget\Link; + +/** + * User item of a user list. Represents one database row. + * + * @property User $item + * @property UserTable $table + */ +class UserTableRow extends BaseTableRowItem +{ + protected $defaultAttributes = ['class' => 'user-table-row']; + + protected function init(): void + { + if (isset($this->table)) { + $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); + } + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $visual->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => 'user-ball']), + Text::create($this->item->display_name[0]) + )); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml( + isset($this->table) + ? new Link($this->item->display_name, Links::user($this->item), ['class' => 'subject']) + : new HtmlElement( + 'span', + Attributes::create(['class' => 'subject']), + Text::create($this->item->display_name) + ), + new HtmlElement('span', null, Text::create($this->item->name)) + ); + } + + protected function assembleColumns(HtmlDocument $columns): void + { + } +} diff --git a/library/Icingadb/Widget/ItemTable/UsergroupTable.php b/library/Icingadb/Widget/ItemTable/UsergroupTable.php new file mode 100644 index 0000000..77d3ba9 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/UsergroupTable.php @@ -0,0 +1,27 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\DetailActions; +use ipl\Web\Common\BaseItemTable; +use ipl\Web\Url; + +class UsergroupTable extends BaseItemTable +{ + use DetailActions; + + protected $defaultAttributes = ['class' => 'usergroup-table']; + + protected function init(): void + { + $this->initializeDetailActions(); + $this->setDetailUrl(Url::fromPath('icingadb/usergroup')); + } + + protected function getItemClass(): string + { + return UsergroupTableRow::class; + } +} diff --git a/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php b/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php new file mode 100644 index 0000000..c3cbf74 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php @@ -0,0 +1,61 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget\ItemTable; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\Usergroup; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTableRowItem; +use ipl\Web\Widget\Link; + +/** + * Usergroup item of a usergroup list. Represents one database row. + * + * @property Usergroup $item + * @property UsergroupTable $table + */ +class UsergroupTableRow extends BaseTableRowItem +{ + protected $defaultAttributes = ['class' => 'usergroup-table-row']; + + protected function init(): void + { + if (isset($this->table)) { + $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); + } + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $visual->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => 'usergroup-ball']), + Text::create($this->item->display_name[0]) + )); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml( + isset($this->table) + ? new Link($this->item->display_name, Links::usergroup($this->item), ['class' => 'subject']) + : new HtmlElement( + 'span', + Attributes::create(['class' => 'subject']), + Text::create($this->item->display_name) + ), + new HtmlElement('span', null, Text::create($this->item->name)) + ); + } + + protected function assembleColumns(HtmlDocument $columns): void + { + } +} diff --git a/library/Icingadb/Widget/MarkdownLine.php b/library/Icingadb/Widget/MarkdownLine.php new file mode 100644 index 0000000..74c413d --- /dev/null +++ b/library/Icingadb/Widget/MarkdownLine.php @@ -0,0 +1,28 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Web\Helper\Markdown; +use ipl\Html\BaseHtmlElement; +use ipl\Html\DeferredText; + +class MarkdownLine extends BaseHtmlElement +{ + protected $tag = 'section'; + + protected $defaultAttributes = ['class' => ['markdown', 'inline']]; + + /** + * MarkdownLine constructor. + * + * @param string $line + */ + public function __construct(string $line) + { + $this->add((new DeferredText(function () use ($line) { + return Markdown::line($line); + }))->setEscaped(true)); + } +} diff --git a/library/Icingadb/Widget/MarkdownText.php b/library/Icingadb/Widget/MarkdownText.php new file mode 100644 index 0000000..43db03e --- /dev/null +++ b/library/Icingadb/Widget/MarkdownText.php @@ -0,0 +1,28 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Web\Helper\Markdown; +use ipl\Html\BaseHtmlElement; +use ipl\Html\DeferredText; + +class MarkdownText extends BaseHtmlElement +{ + protected $tag = 'section'; + + protected $defaultAttributes = ['class' => 'markdown']; + + /** + * MarkdownText constructor. + * + * @param string $text + */ + public function __construct(string $text) + { + $this->add((new DeferredText(function () use ($text) { + return Markdown::text($text); + }))->setEscaped(true)); + } +} diff --git a/library/Icingadb/Widget/Notice.php b/library/Icingadb/Widget/Notice.php new file mode 100644 index 0000000..3ed6dad --- /dev/null +++ b/library/Icingadb/Widget/Notice.php @@ -0,0 +1,36 @@ +<?php + +/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Web\Widget\Icon; + +class Notice extends BaseHtmlElement +{ + /** @var mixed */ + protected $content; + + protected $tag = 'p'; + + protected $defaultAttributes = ['class' => 'notice']; + + /** + * Create a html notice + * + * @param mixed $content + */ + public function __construct($content) + { + $this->content = $content; + } + + protected function assemble(): void + { + $this->addHtml(new Icon('triangle-exclamation')); + $this->addHtml((new HtmlElement('span'))->add($this->content)); + $this->addHtml(new Icon('triangle-exclamation')); + } +} diff --git a/library/Icingadb/Widget/PluginOutputContainer.php b/library/Icingadb/Widget/PluginOutputContainer.php new file mode 100644 index 0000000..a8ff578 --- /dev/null +++ b/library/Icingadb/Widget/PluginOutputContainer.php @@ -0,0 +1,22 @@ +<?php + +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Module\Icingadb\Util\PluginOutput; +use ipl\Html\BaseHtmlElement; + +class PluginOutputContainer extends BaseHtmlElement +{ + protected $tag = 'div'; + + public function __construct(PluginOutput $output) + { + $this->setHtmlContent($output); + + $this->getAttributes()->registerAttributeCallback('class', function () use ($output) { + return $output->isHtml() ? 'plugin-output' : 'plugin-output preformatted'; + }); + } +} diff --git a/library/Icingadb/Widget/ServiceStateBadges.php b/library/Icingadb/Widget/ServiceStateBadges.php new file mode 100644 index 0000000..fee2586 --- /dev/null +++ b/library/Icingadb/Widget/ServiceStateBadges.php @@ -0,0 +1,46 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Common\ServiceStates; +use Icinga\Module\Icingadb\Common\StateBadges; +use ipl\Web\Url; + +class ServiceStateBadges extends StateBadges +{ + protected function getBaseUrl(): Url + { + return Links::services(); + } + + protected function getType(): string + { + return 'service'; + } + + protected function getPrefix(): string + { + return 'services'; + } + + protected function getStateInt(string $state): int + { + return ServiceStates::int($state); + } + + protected function assemble() + { + $this->addAttributes(['class' => 'service-state-badges']); + + $this->add(array_filter([ + $this->createGroup('critical'), + $this->createGroup('warning'), + $this->createGroup('unknown'), + $this->createBadge('ok'), + $this->createBadge('pending') + ])); + } +} diff --git a/library/Icingadb/Widget/ServiceStatusBar.php b/library/Icingadb/Widget/ServiceStatusBar.php new file mode 100644 index 0000000..56f47aa --- /dev/null +++ b/library/Icingadb/Widget/ServiceStatusBar.php @@ -0,0 +1,24 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Module\Icingadb\Common\BaseStatusBar; +use ipl\Html\BaseHtmlElement; + +class ServiceStatusBar extends BaseStatusBar +{ + protected function assembleTotal(BaseHtmlElement $total): void + { + $total->add(sprintf( + tp('%d Service', '%d Services', $this->summary->services_total), + $this->summary->services_total + )); + } + + protected function createStateBadges(): BaseHtmlElement + { + return (new ServiceStateBadges($this->summary))->setBaseFilter($this->getBaseFilter()); + } +} diff --git a/library/Icingadb/Widget/ServiceSummaryDonut.php b/library/Icingadb/Widget/ServiceSummaryDonut.php new file mode 100644 index 0000000..e806fba --- /dev/null +++ b/library/Icingadb/Widget/ServiceSummaryDonut.php @@ -0,0 +1,81 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use Icinga\Chart\Donut; +use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Model\ServicestateSummary; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\TemplateString; +use ipl\Html\Text; +use ipl\Stdlib\BaseFilter; +use ipl\Stdlib\Filter; +use ipl\Web\Common\Card; + +class ServiceSummaryDonut extends Card +{ + use BaseFilter; + + protected $defaultAttributes = ['class' => 'donut-container', 'data-base-target' => '_next']; + + /** @var ServicestateSummary */ + protected $summary; + + public function __construct(ServicestateSummary $summary) + { + $this->summary = $summary; + } + + protected function assembleBody(BaseHtmlElement $body) + { + $labelBigUrlFilter = Filter::all( + Filter::equal('service.state.soft_state', 2), + Filter::equal('service.state.is_handled', 'n') + ); + if ($this->hasBaseFilter()) { + $labelBigUrlFilter->add($this->getBaseFilter()); + } + + $donut = (new Donut()) + ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok']) + ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled']) + ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning']) + ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled']) + ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical']) + ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled']) + ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown']) + ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']) + ->setLabelBig($this->summary->services_critical_unhandled) + ->setLabelBigUrl(Links::services()->setFilter($labelBigUrlFilter)->addParams([ + 'sort' => 'service.state.last_state_change' + ])) + ->setLabelBigEyeCatching($this->summary->services_critical_unhandled > 0) + ->setLabelSmall(t('Critical')); + + $body->addHtml( + new HtmlElement('div', Attributes::create(['class' => 'donut']), new HtmlString($donut->render())) + ); + } + + protected function assembleFooter(BaseHtmlElement $footer) + { + $footer->addHtml((new ServiceStateBadges($this->summary))->setBaseFilter($this->getBaseFilter())); + } + + protected function assembleHeader(BaseHtmlElement $header) + { + $header->addHtml( + new HtmlElement('h2', null, Text::create(t('Services'))), + new HtmlElement('span', Attributes::create(['class' => 'meta']), TemplateString::create( + t('{{#total}}Total{{/total}} %d'), + ['total' => new HtmlElement('span')], + (int) $this->summary->services_total + )) + ); + } +} diff --git a/library/Icingadb/Widget/ShowMore.php b/library/Icingadb/Widget/ShowMore.php new file mode 100644 index 0000000..d7fc7fb --- /dev/null +++ b/library/Icingadb/Widget/ShowMore.php @@ -0,0 +1,64 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Orm\ResultSet; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Url; +use ipl\Web\Widget\ActionLink; + +class ShowMore extends BaseHtmlElement +{ + use BaseTarget; + + protected $defaultAttributes = ['class' => 'show-more']; + + protected $tag = 'div'; + + /** @var ResultSet */ + protected $resultSet; + + /** @var Url */ + protected $url; + + /** @var ?string */ + protected $label; + + public function __construct(ResultSet $resultSet, Url $url, string $label = null) + { + $this->label = $label; + $this->resultSet = $resultSet; + $this->url = $url; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getLabel(): string + { + return $this->label ?: t('Show More'); + } + + public function renderUnwrapped(): string + { + if ($this->resultSet->hasMore()) { + return parent::renderUnwrapped(); + } + + return ''; + } + + protected function assemble(): void + { + if ($this->resultSet->hasMore()) { + $this->addHtml(new ActionLink($this->getLabel(), $this->url)); + } + } +} diff --git a/library/Icingadb/Widget/StateBadge.php b/library/Icingadb/Widget/StateBadge.php new file mode 100644 index 0000000..d947590 --- /dev/null +++ b/library/Icingadb/Widget/StateBadge.php @@ -0,0 +1,10 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +/** @deprecated Use {@see \ipl\Web\Widget\StateBadge} instead */ +class StateBadge extends \ipl\Web\Widget\StateBadge +{ +} diff --git a/library/Icingadb/Widget/StateChange.php b/library/Icingadb/Widget/StateChange.php new file mode 100644 index 0000000..a9987be --- /dev/null +++ b/library/Icingadb/Widget/StateChange.php @@ -0,0 +1,133 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; + +class StateChange extends BaseHtmlElement +{ + protected $previousState; + + protected $state; + + protected $previousStateBallSize = StateBall::SIZE_BIG; + + protected $currentStateBallSize = StateBall::SIZE_BIG; + + protected $defaultAttributes = ['class' => 'state-change']; + + protected $tag = 'div'; + + /** @var ?Icon Current state ball icon */ + protected $icon; + + /** @var bool Whether the state is handled */ + protected $isHandled = false; + + public function __construct(string $state, string $previousState) + { + $this->previousState = $previousState; + $this->state = $state; + } + + /** + * Set the state ball size for the previous state + * + * @param string $size + * + * @return $this + */ + public function setPreviousStateBallSize(string $size): self + { + $this->previousStateBallSize = $size; + + return $this; + } + + /** + * Set the state ball size for the current state + * + * @param string $size + * + * @return $this + */ + public function setCurrentStateBallSize(string $size): self + { + $this->currentStateBallSize = $size; + + return $this; + } + + /** + * Set the current state ball icon + * + * @param $icon + * + * @return $this + */ + public function setIcon($icon): self + { + $this->icon = $icon; + + return $this; + } + + /** + * Set whether the current state is handled + * + * @return $this + */ + public function setHandled($isHandled = true): self + { + $this->isHandled = $isHandled; + + return $this; + } + + protected function assemble() + { + $currentStateBall = (new StateBall($this->state, $this->currentStateBallSize)) + ->add($this->icon); + + if ($this->isHandled) { + $currentStateBall->getAttributes()->add('class', 'handled'); + } + + $previousStateBall = new StateBall($this->previousState, $this->previousStateBallSize); + if ($this->isRightBiggerThanLeft()) { + $this->getAttributes()->add('class', 'reversed-state-balls'); + + $this->addHtml($currentStateBall, $previousStateBall); + } else { + $this->addHtml($previousStateBall, $currentStateBall); + } + } + + protected function isRightBiggerThanLeft(): bool + { + $left = $this->previousStateBallSize; + $right = $this->currentStateBallSize; + + if ($left === $right) { + return false; + } elseif ($left === StateBall::SIZE_LARGE) { + return false; + } + + $map = [ + StateBall::SIZE_BIG => [false, [StateBall::SIZE_LARGE]], + StateBall::SIZE_MEDIUM_LARGE => [false, [StateBall::SIZE_BIG, StateBall::SIZE_LARGE]], + StateBall::SIZE_MEDIUM => [true, [StateBall::SIZE_TINY, StateBall::SIZE_SMALL]], + StateBall::SIZE_SMALL => [true, [StateBall::SIZE_TINY]] + ]; + + list($negate, $sizes) = $map[$left]; + $found = in_array($right, $sizes, true); + + return ($negate && ! $found) || (! $negate && $found); + } +} diff --git a/library/Icingadb/Widget/TagList.php b/library/Icingadb/Widget/TagList.php new file mode 100644 index 0000000..6a28a9c --- /dev/null +++ b/library/Icingadb/Widget/TagList.php @@ -0,0 +1,35 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Widget\Link; + +class TagList extends BaseHtmlElement +{ + protected $content = []; + + protected $defaultAttributes = ['class' => 'tag-list']; + + protected $tag = 'div'; + + public function addLink($content, $url): self + { + $this->content[] = new Link($content, $url); + + return $this; + } + + public function hasContent(): bool + { + return ! empty($this->content); + } + + protected function assemble() + { + $this->add(Html::wrapEach($this->content, 'li')); + } +} |