summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Util/PluginOutput.php
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icingadb/Util/PluginOutput.php')
-rw-r--r--library/Icingadb/Util/PluginOutput.php260
1 files changed, 260 insertions, 0 deletions
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])/', ',&#8203;', $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);
+ }
+}