diff options
Diffstat (limited to 'library/Businessprocess')
74 files changed, 9925 insertions, 0 deletions
diff --git a/library/Businessprocess/BpConfig.php b/library/Businessprocess/BpConfig.php new file mode 100644 index 0000000..c9e70fd --- /dev/null +++ b/library/Businessprocess/BpConfig.php @@ -0,0 +1,1117 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Exception; +use Icinga\Application\Modules\Module; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Businessprocess\Exception\NestingError; +use Icinga\Module\Businessprocess\Modification\ProcessChanges; +use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport; +use Icinga\Module\Businessprocess\Storage\LegacyStorage; +use Icinga\Module\Monitoring\Backend\MonitoringBackend; +use ipl\Sql\Connection as IcingaDbConnection; + +class BpConfig +{ + const SOFT_STATE = 0; + + const HARD_STATE = 1; + + /** + * Name of the configured monitoring backend + * + * @var string + */ + protected $backendName; + + /** + * Backend to retrieve states from + * + * @var MonitoringBackend|IcingaDbConnection + */ + protected $backend; + + /** + * @var LegacyStorage + */ + protected $storage; + + /** @var Metadata */ + protected $metadata; + + /** + * Business process name + * + * @var string + */ + protected $name; + + /** + * Business process title + * + * @var string + */ + protected $title; + + /** + * State type, soft or hard + * + * @var int + */ + protected $state_type; + + /** + * Warnings, usually filled at process build time + * + * @var array + */ + protected $warnings = array(); + + /** + * Errors, usually filled at process build time + * + * @var array + */ + protected $errors = array(); + + /** + * All used node objects + * + * @var array + */ + protected $nodes = array(); + + /** + * Root node objects + * + * @var array + */ + protected $root_nodes = array(); + + /** + * Imported nodes + * + * @var ImportedNode[] + */ + protected $importedNodes = []; + + /** + * Imported configs + * + * @var BpConfig[] + */ + protected $importedConfigs = []; + + /** + * All host names { 'hostA' => true, ... } + * + * @var array + */ + protected $hosts = array(); + + /** @var bool Whether catchable errors should be thrown nonetheless */ + protected $throwErrors = false; + + protected $loopDetection = array(); + + /** + * Applied state simulation + * + * @var Simulation + */ + protected $simulation; + + protected $changeCount = 0; + + protected $simulationCount = 0; + + /** @var ProcessChanges */ + protected $appliedChanges; + + /** @var bool Whether the config is faulty */ + protected $isFaulty = false; + + public function __construct() + { + } + + /** + * Retrieve metadata for this configuration + * + * @return Metadata + */ + public function getMetadata() + { + if ($this->metadata === null) { + $this->metadata = new Metadata($this->name); + } + + return $this->metadata; + } + + /** + * Set metadata + * + * @param Metadata $metadata + * + * @return $this + */ + public function setMetadata(Metadata $metadata) + { + $this->metadata = $metadata; + return $this; + } + + /** + * Apply pending process changes + * + * @param ProcessChanges $changes + * + * @return $this + */ + public function applyChanges(ProcessChanges $changes) + { + $cnt = 0; + foreach ($changes->getChanges() as $change) { + $cnt++; + $change->applyTo($this); + } + $this->changeCount = $cnt; + + $this->appliedChanges = $changes; + + return $this; + } + + /** + * Apply a state simulation + * + * @param Simulation $simulation + * + * @return $this + */ + public function applySimulation(Simulation $simulation) + { + $cnt = 0; + + foreach ($simulation->simulations() as $node => $s) { + if (! $this->hasNode($node)) { + continue; + } + $cnt++; + $this->getNode($node) + ->setState($s->state) + ->setAck($s->acknowledged) + ->setDowntime($s->in_downtime) + ->setMissing(false); + } + + $this->simulationCount = $cnt; + + return $this; + } + + /** + * Number of applied changes + * + * @return int + */ + public function countChanges() + { + return $this->changeCount; + } + + /** + * Whether changes have been applied to this configuration + * + * @return bool + */ + public function hasChanges() + { + return $this->countChanges() > 0; + } + + /** + * @param $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return string + */ + public function getHtmlId() + { + return 'businessprocess-' . preg_replace('/[\r\n\t\s]/', '_', $this->getName()); + } + + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + public function getTitle() + { + return $this->getMetadata()->getTitle(); + } + + public function hasTitle() + { + return $this->getMetadata()->has('Title'); + } + + public function getBackendName() + { + return $this->getMetadata()->get('Backend'); + } + + public function hasBackendName() + { + return $this->getMetadata()->has('Backend'); + } + + public function setBackend($backend) + { + $this->backend = $backend; + return $this; + } + + public function getBackend() + { + if ($this->backend === null) { + if (Module::exists('icingadb') + && (! $this->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())) { + $this->backend = IcingaDbObject::fetchDb(); + } else { + $this->backend = MonitoringBackend::instance( + $this->getBackendName() + ); + } + } + + return $this->backend; + } + + public function isReferenced() + { + foreach ($this->storage()->listProcessNames() as $bpName) { + if ($bpName !== $this->getName()) { + $bp = $this->storage()->loadProcess($bpName); + foreach ($bp->getImportedNodes() as $importedNode) { + if ($importedNode->getConfigName() === $this->getName()) { + return true; + } + } + } + } + + return false; + } + + public function hasBackend() + { + return $this->backend !== null; + } + + public function hasBeenChanged() + { + return false; + } + + public function hasSimulations() + { + return $this->countSimulations() > 0; + } + + public function countSimulations() + { + return $this->simulationCount; + } + + public function clearAppliedChanges() + { + if ($this->appliedChanges !== null) { + $this->appliedChanges->clear(); + } + return $this; + } + + public function getStateType() + { + if ($this->state_type === null) { + if ($this->getMetadata()->has('Statetype')) { + switch ($this->getMetadata()->get('Statetype')) { + case 'hard': + $this->state_type = self::HARD_STATE; + break; + case 'soft': + $this->state_type = self::SOFT_STATE; + break; + } + } else { + $this->state_type = self::HARD_STATE; + } + } + + return $this->state_type; + } + + public function useSoftStates() + { + $this->state_type = self::SOFT_STATE; + return $this; + } + + public function useHardStates() + { + $this->state_type = self::HARD_STATE; + return $this; + } + + public function usesSoftStates() + { + return $this->getStateType() === self::SOFT_STATE; + } + + public function usesHardStates() + { + return $this->getStateType() === self::HARD_STATE; + } + + public function addRootNode($name) + { + $this->root_nodes[$name] = $this->getNode($name); + return $this; + } + + public function removeRootNode($name) + { + if ($this->isRootNode($name)) { + unset($this->root_nodes[$name]); + } + + return $this; + } + + public function isRootNode($name) + { + return array_key_exists($name, $this->root_nodes); + } + + /** + * @return BpNode[] + */ + public function getChildren() + { + return $this->getRootNodes(); + } + + /** + * @return int + */ + public function countChildren() + { + return count($this->root_nodes); + } + + /** + * @return BpNode[] + */ + public function getRootNodes() + { + return $this->root_nodes; + } + + public function listRootNodes() + { + return array_keys($this->root_nodes); + } + + public function getNodes() + { + return $this->nodes; + } + + public function hasNode($name) + { + if (array_key_exists($name, $this->nodes)) { + return true; + } elseif ($name[0] === '@') { + list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2); + return $this->getImportedConfig($configName)->hasNode($nodeName); + } + + return false; + } + + public function hasRootNode($name) + { + return array_key_exists($name, $this->root_nodes); + } + + public function createService($host, $service) + { + $node = new ServiceNode( + (object) array( + 'hostname' => $host, + 'service' => $service + ) + ); + $node->setBpConfig($this); + $this->nodes[$node->getName()] = $node; + $this->hosts[$host] = true; + return $node; + } + + public function createHost($host) + { + $node = new HostNode((object) array('hostname' => $host)); + $node->setBpConfig($this); + $this->nodes[$node->getName()] = $node; + $this->hosts[$host] = true; + return $node; + } + + public function calculateAllStates() + { + foreach ($this->getRootNodes() as $node) { + $node->getState(); + } + + return $this; + } + + public function clearAllStates() + { + foreach ($this->getBpNodes() as $node) { + $node->clearState(); + } + + return $this; + } + + public function listInvolvedHostNames(&$usedConfigs = null) + { + $hosts = $this->hosts; + if (! empty($this->importedNodes)) { + $usedConfigs[$this->getName()] = true; + foreach ($this->importedNodes as $node) { + if (isset($usedConfigs[$node->getConfigName()])) { + continue; + } + + $hosts += array_flip($node->getBpConfig()->listInvolvedHostNames($usedConfigs)); + } + } + + return array_keys($hosts); + } + + /** + * Create and attach a new process (BpNode) + * + * @param string $name Process name + * @param string $operator Operator (defaults to &) + * + * @return BpNode + */ + public function createBp($name, $operator = '&') + { + $node = new BpNode((object) array( + 'name' => $name, + 'operator' => $operator, + 'child_names' => array(), + )); + $node->setBpConfig($this); + + $this->addNode($name, $node); + return $node; + } + + public function createMissingBp($name) + { + return $this->createBp($name)->setMissing(); + } + + public function getMissingChildren() + { + $missing = array(); + foreach ($this->getRootNodes() as $root) { + $missing += $root->getMissingChildren(); + } + + return $missing; + } + + public function createImportedNode($config, $name = null) + { + $params = (object) array('configName' => $config); + if ($name !== null) { + $params->node = $name; + } + + $node = new ImportedNode($this, $params); + $this->importedNodes[$node->getName()] = $node; + $this->nodes[$node->getName()] = $node; + return $node; + } + + public function getImportedNodes() + { + return $this->importedNodes; + } + + public function getImportedConfig($name) + { + if (! isset($this->importedConfigs[$name])) { + try { + $import = $this->storage()->loadProcess($name); + } catch (Exception $e) { + $import = (new static()) + ->setName($name) + ->setFaulty(); + } + + if ($this->usesSoftStates()) { + $import->useSoftStates(); + } else { + $import->useHardStates(); + } + + $this->importedConfigs[$name] = $import; + } + + return $this->importedConfigs[$name]; + } + + public function listInvolvedConfigs(&$configs = null) + { + if ($configs === null) { + $configs[$this->getName()] = $this; + } + + foreach ($this->importedNodes as $node) { + if (! isset($configs[$node->getConfigName()])) { + $config = $node->getBpConfig(); + $configs[$node->getConfigName()] = $config; + $config->listInvolvedConfigs($configs); + } + } + + return $configs; + } + + /** + * @return LegacyStorage + */ + protected function storage() + { + if ($this->storage === null) { + $this->storage = LegacyStorage::getInstance(); + } + + return $this->storage; + } + + /** + * @param string $name + * @return MonitoredNode|BpNode + * @throws Exception + */ + public function getNode($name) + { + if ($name === '__unbound__') { + return $this->getUnboundBaseNode(); + } + + if (array_key_exists($name, $this->nodes)) { + return $this->nodes[$name]; + } + + if ($name[0] === '@') { + list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2); + return $this->getImportedConfig($configName)->getNode($nodeName); + } + + // Fallback: if it is a service, create an empty one: + $this->warn(sprintf('The node "%s" doesn\'t exist', $name)); + + [$name, $suffix] = self::splitNodeName($name); + if ($suffix !== null) { + if ($suffix === 'Hoststatus') { + return $this->createHost($name); + } else { + return $this->createService($name, $suffix); + } + } + + throw new Exception( + sprintf('The node "%s" doesn\'t exist', $name) + ); + } + + /** + * @return BpNode + */ + public function getUnboundBaseNode() + { + // Hint: state is useless here, but triggers parent/child "calculation" + // This is an ugly workaround and should be made obsolete + $this->calculateAllStates(); + + $names = array_keys($this->getUnboundNodes()); + $bp = new BpNode((object) array( + 'name' => '__unbound__', + 'operator' => '&', + 'child_names' => $names + )); + $bp->setBpConfig($this); + $bp->setAlias($this->translate('Unbound nodes')); + return $bp; + } + + /** + * @param $name + * @return BpNode + * + * @throws NotFoundError + */ + public function getBpNode($name) + { + if ($this->hasBpNode($name)) { + return $this->nodes[$name]; + } + + $msg = $this->isFaulty() + ? sprintf( + t('Trying to import node "%s" from faulty config file "%s.conf"'), + self::unescapeName($name), + $this->getName() + ) + : sprintf(t('Trying to access a missing business process node "%s"'), $name); + + throw new NotFoundError($msg); + } + + /** + * @param $name + * + * @return bool + */ + public function hasBpNode($name) + { + return array_key_exists($name, $this->nodes) + && $this->nodes[$name] instanceof BpNode; + } + + /** + * Set the state for a specific node + * + * @param string $name Node name + * @param int $state Desired state + * + * @return $this + */ + public function setNodeState($name, $state) + { + $this->getNode($name)->setState($state); + return $this; + } + + /** + * Add the given node to the given BpNode + * + * @param $name + * @param BpNode $node + * + * @return $this + */ + public function addNode($name, BpNode $node) + { + if (array_key_exists($name, $this->nodes)) { + $this->warn( + sprintf( + mt('businessprocess', 'Node "%s" has been defined twice'), + $name + ) + ); + } + + $this->nodes[$name] = $node; + + if ($node->getDisplay() > 0) { + if (! $this->isRootNode($name)) { + $this->addRootNode($name); + } + } else { + if ($this->isRootNode($name)) { + $this->removeRootNode($name); + } + } + + + return $this; + } + + /** + * Remove all occurrences of a specific node by name + * + * @param $name + */ + public function removeNode($name) + { + unset($this->nodes[$name]); + if (array_key_exists($name, $this->root_nodes)) { + unset($this->root_nodes[$name]); + } + + foreach ($this->getBpNodes() as $node) { + if ($node->hasChild($name)) { + $node->removeChild($name); + } + } + } + + /** + * Get all business process nodes + * + * @return BpNode[] + */ + public function getBpNodes() + { + $nodes = array(); + + foreach ($this->nodes as $node) { + if ($node instanceof BpNode) { + $nodes[$node->getName()] = $node; + } + } + + return $nodes; + } + + /** + * List all business process node names + * + * @return array + */ + public function listBpNodes() + { + $nodes = array(); + + foreach ($this->getBpNodes() as $name => $node) { + $alias = $node->getAlias(); + $nodes[$name] = $name === $alias ? $name : sprintf('%s (%s)', $alias, $node); + } + + return $nodes; + } + + /** + * All business process nodes defined in this config but not + * assigned to any parent + * + * @return BpNode[] + */ + public function getUnboundNodes() + { + $nodes = array(); + + foreach ($this->getBpNodes() as $name => $node) { + if ($node->hasParents()) { + continue; + } + + if ($node->getDisplay() === 0) { + $nodes[$name] = $node; + } + } + + return $nodes; + } + + /** + * @return bool + */ + public function hasWarnings() + { + return ! empty($this->warnings); + } + + /** + * @return array + */ + public function getWarnings() + { + return $this->warnings; + } + + /** + * @return bool + */ + public function hasErrors() + { + return ! empty($this->errors) || $this->isEmpty(); + } + + /** + * @return array + */ + public function getErrors() + { + $errors = $this->errors; + if ($this->isEmpty()) { + $errors[] = sprintf( + $this->translate( + 'No business process nodes for "%s" have been defined yet' + ), + $this->getTitle() + ); + } + return $errors; + } + + /** + * Translation helper + * + * @param $msg + * + * @return mixed|string + */ + public function translate($msg) + { + return mt('businessprocess', $msg); + } + + /** + * Add a message to our warning stack + * + * @param $msg + */ + protected function warn($msg) + { + $args = func_get_args(); + array_shift($args); + $this->warnings[] = vsprintf($msg, $args); + } + + /** + * @param string $msg,... + * + * @return $this + * + * @throws IcingaException + */ + public function addError($msg) + { + $args = func_get_args(); + array_shift($args); + if (! empty($args)) { + $msg = vsprintf($msg, $args); + } + if ($this->throwErrors) { + throw new IcingaException($msg); + } + + if (! in_array($msg, $this->errors)) { + $this->errors[] = $msg; + } + + return $this; + } + + /** + * Decide whether errors should be thrown or collected + * + * @param bool $throw + * + * @return $this + */ + public function throwErrors($throw = true) + { + $this->throwErrors = $throw; + return $this; + } + + /** + * Begin loop detection for the given name + * + * Will throw a NestingError in case this node will be met again below itself + * + * @param $name + * + * @throws NestingError + */ + public function beginLoopDetection($name) + { + // echo "Begin loop $name\n"; + if (array_key_exists($name, $this->loopDetection)) { + $loop = array_keys($this->loopDetection); + $loop[] = $name; + $this->loopDetection = array(); + throw new NestingError('Loop detected: %s', implode(' -> ', $loop)); + } + + $this->loopDetection[$name] = true; + } + + /** + * Remove the given name from the loop detection stack + * + * @param $name + */ + public function endLoopDetection($name) + { + // echo "End loop $this->name\n"; + unset($this->loopDetection[$name]); + } + + /** + * Whether this configuration has any Nodes + * + * @return bool + */ + public function isEmpty() + { + // This is faster + if (! empty($this->root_nodes)) { + return false; + } + + return count($this->listBpNodes()) === 0; + } + + /** + * Export the config to array + * + * @param bool $flat If false, children will be added to the array key children, else the array will be flat + * + * @return array + */ + public function toArray($flat = false) + { + $data = [ + 'name' => $this->getTitle(), + 'path' => $this->getTitle() + ]; + + $children = []; + + foreach ($this->getChildren() as $node) { + if ($flat) { + $children = array_merge($children, $node->toArray($data, $flat)); + } else { + $children[] = $node->toArray($data, $flat); + } + } + + if ($flat) { + $data = [$data]; + + if (! empty($children)) { + $data = array_merge($data, $children); + } + } else { + $data['children'] = $children; + } + + return $data; + } + + /** + * Escape the given node name + * + * @param string $name + * + * @return string + */ + public static function escapeName(string $name): string + { + return preg_replace('/((?<!\\\\);)/', '\\\\$1', $name); + } + + /** + * Unescape the given node name + * + * @param string $name + * + * @return string + */ + public static function unescapeName(string $name): string + { + return str_replace('\\;', ';', $name); + } + + /** + * Join the given two name parts together + * + * The used separator is the semicolon. If a semicolon exists in either part, it's escaped. + * + * @param string $name + * @param ?string $suffix + * + * @return string + */ + public static function joinNodeName(string $name, ?string $suffix = null): string + { + return self::escapeName($name) . ($suffix ? ";$suffix" : ''); + } + + /** + * Split the given node name into two parts + * + * The first part is always a string, with any semicolons unescaped. + * The second part may be null or a string otherwise. + * + * @param string $nodeName + * + * @return array + */ + public static function splitNodeName(string $nodeName): array + { + $parts = preg_split('/(?<!\\\\);/', $nodeName, 2); + $parts[0] = self::unescapeName($parts[0]); + + return array_pad($parts, 2, null); + } + + /** + * Set whether the config is faulty + * + * @param bool $isFaulty + * + * @return $this + */ + public function setFaulty(bool $isFaulty = true): self + { + $this->isFaulty = $isFaulty; + + return $this; + } + + /** + * Get whether the config is faulty + * + * @return bool + */ + public function isFaulty(): bool + { + return $this->isFaulty; + } +} diff --git a/library/Businessprocess/BpNode.php b/library/Businessprocess/BpNode.php new file mode 100644 index 0000000..ab76e3e --- /dev/null +++ b/library/Businessprocess/BpNode.php @@ -0,0 +1,646 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Businessprocess\Exception\NestingError; +use ipl\Web\Widget\Icon; + +class BpNode extends Node +{ + const OP_AND = '&'; + const OP_OR = '|'; + const OP_XOR = '^'; + const OP_NOT = '!'; + const OP_DEGRADED = '%'; + + protected $operator = '&'; + + protected $url; + + protected $display = 0; + + /** @var ?Node[] */ + protected $children; + + /** @var array */ + protected $childNames = array(); + protected $counters; + protected $missing = null; + protected $empty = null; + protected $missingChildren; + protected $stateOverrides = []; + + protected static $emptyStateSummary = array( + 'CRITICAL' => 0, + 'CRITICAL-HANDLED' => 0, + 'WARNING' => 0, + 'WARNING-HANDLED' => 0, + 'UNKNOWN' => 0, + 'UNKNOWN-HANDLED' => 0, + 'OK' => 0, + 'PENDING' => 0, + 'MISSING' => 0, + 'EMPTY' => 0, + ); + + protected static $sortStateInversionMap = array( + 4 => 0, + 3 => 0, + 2 => 2, + 1 => 1, + 0 => 4 + ); + + protected $className = 'process'; + + public function __construct($object) + { + $this->name = BpConfig::escapeName($object->name); + $this->alias = BpConfig::unescapeName($object->name); + $this->operator = $object->operator; + $this->childNames = $object->child_names; + } + + public function getStateSummary() + { + if ($this->counters === null) { + $this->getState(); + $this->counters = self::$emptyStateSummary; + + foreach ($this->getChildren() as $child) { + if ($child->isMissing()) { + $this->counters['MISSING']++; + } else { + $state = $child->getStateName($this->getChildState($child)); + if ($child->isHandled() && ($state !== 'UP' && $state !== 'OK')) { + $state = $state . '-HANDLED'; + } + + if ($state === 'DOWN') { + $this->counters['CRITICAL']++; + } elseif ($state === 'DOWN-HANDLED') { + $this->counters['CRITICAL-HANDLED']++; + } elseif ($state === 'UNREACHABLE') { + $this->counters['UNKNOWN']++; + } elseif ($state === 'UNREACHABLE-HANDLED') { + $this->counters['UNKNOWN-HANDLED']++; + } elseif ($state === 'PENDING-HANDLED') { + $this->counters['PENDING']++; + } elseif ($state === 'UP') { + $this->counters['OK']++; + } else { + $this->counters[$state]++; + } + } + } + } + return $this->counters; + } + + public function hasProblems() + { + if ($this->isProblem()) { + return true; + } + + $okStates = array('OK', 'UP', 'PENDING', 'MISSING'); + + foreach ($this->getStateSummary() as $state => $cnt) { + if ($cnt !== 0 && ! in_array($state, $okStates)) { + return true; + } + } + + return false; + } + + /** + * @param Node $node + * @return $this + * @throws ConfigurationError + */ + public function addChild(Node $node) + { + if ($this->children === null) { + $this->getChildren(); + } + + $name = $node->getName(); + if (array_key_exists($name, $this->children)) { + throw new ConfigurationError( + 'Node "%s" has been defined more than once', + $name + ); + } + + $this->children[$name] = $node; + $this->childNames[] = $name; + $node->addParent($this); + return $this; + } + + public function getProblematicChildren() + { + $problems = array(); + + foreach ($this->getChildren() as $child) { + if (isset($this->stateOverrides[$child->getName()])) { + $problem = $this->getChildState($child) > 0; + } else { + $problem = $child->isProblem() || ($child instanceof BpNode && $child->hasProblems()); + } + + if ($problem) { + $problems[] = $child; + } + } + + return $problems; + } + + public function hasChild($name) + { + return in_array($name, $this->getChildNames()); + } + + public function removeChild($name) + { + if (($key = array_search($name, $this->getChildNames())) !== false) { + unset($this->childNames[$key]); + + if (! empty($this->children)) { + unset($this->children[$name]); + } + + $this->childNames = array_values($this->childNames); + } + + return $this; + } + + public function getProblemTree() + { + $tree = array(); + + foreach ($this->getProblematicChildren() as $child) { + $name = $child->getName(); + $tree[$name] = array( + 'node' => $child, + 'children' => array() + ); + if ($child instanceof BpNode) { + $tree[$name]['children'] = $child->getProblemTree(); + } + } + + return $tree; + } + + /** + * Get the problem nodes as tree reduced to the nodes which have the same state as the business process + * + * @param bool $rootCause Reduce nodes to the nodes which are responsible for the state of the business process + * + * @return array + */ + public function getProblemTreeBlame($rootCause = false) + { + $tree = []; + $nodeState = $this->getState(); + + if ($nodeState !== 0) { + foreach ($this->getChildren() as $child) { + $childState = $this->getChildState($child); + $childState = $rootCause ? $child->getSortingState($childState) : $childState; + if (($rootCause ? $this->getSortingState() : $nodeState) === $childState) { + $name = $child->getName(); + $tree[$name] = [ + 'children' => [], + 'node' => $child + ]; + if ($child instanceof BpNode) { + $tree[$name]['children'] = $child->getProblemTreeBlame($rootCause); + } + } + } + } + + return $tree; + } + + + public function isMissing() + { + if ($this->missing === null) { + $exists = false; + $bp = $this->getBpConfig(); + $bp->beginLoopDetection($this->name); + foreach ($this->getChildren() as $child) { + if (! $child->isMissing()) { + $exists = true; + } + } + $bp->endLoopDetection($this->name); + $this->missing = ! $exists && ! empty($this->getChildren()); + } + return $this->missing; + } + + public function isEmpty() + { + $bp = $this->getBpConfig(); + $empty = true; + if ($this->countChildren()) { + $bp->beginLoopDetection($this->name); + foreach ($this->getChildren() as $child) { + if ($child instanceof MonitoredNode) { + $empty = false; + break; + } elseif (!$child->isEmpty()) { + $empty = false; + } + } + $bp->endLoopDetection($this->name); + } + $this->empty = $empty; + + return $this->empty; + } + + + public function getMissingChildren() + { + if ($this->missingChildren === null) { + $missing = array(); + + foreach ($this->getChildren() as $child) { + if ($child->isMissing()) { + $missing[$child->getAlias() ?? $child->getName()] = $child; + } + + foreach ($child->getMissingChildren() as $m) { + $missing[$m->getAlias() ?? $m->getName()] = $m; + } + } + + $this->missingChildren = $missing; + } + + return $this->missingChildren; + } + + public function getOperator() + { + return $this->operator; + } + + public function setOperator($operator) + { + $this->assertValidOperator($operator); + $this->operator = $operator; + return $this; + } + + protected function assertValidOperator($operator) + { + switch ($operator) { + case self::OP_AND: + case self::OP_OR: + case self::OP_XOR: + case self::OP_NOT: + case self::OP_DEGRADED: + return; + default: + if (is_numeric($operator)) { + return; + } + } + + throw new ConfigurationError( + 'Got invalid operator: %s', + $operator + ); + } + + public function setInfoUrl($url) + { + $this->url = $url; + return $this; + } + + public function hasInfoUrl() + { + return ! empty($this->url); + } + + public function getInfoUrl() + { + return $this->url; + } + + public function setStateOverrides(array $overrides, $name = null) + { + if ($name === null) { + $this->stateOverrides = $overrides; + } else { + $this->stateOverrides[$name] = $overrides; + } + + return $this; + } + + public function getStateOverrides($name = null) + { + $overrides = null; + if ($name !== null) { + if (isset($this->stateOverrides[$name])) { + $overrides = $this->stateOverrides[$name]; + } + } else { + $overrides = $this->stateOverrides; + } + + return $overrides; + } + + public function getAlias() + { + return $this->alias ? preg_replace('~_~', ' ', $this->alias) : $this->name; + } + + /** + * @return int + */ + public function getState() + { + if ($this->state === null) { + try { + $this->reCalculateState(); + } catch (NestingError $e) { + $this->getBpConfig()->addError( + $this->getBpConfig()->translate('Nesting error detected: %s'), + $e->getMessage() + ); + + // Failing nodes are unknown + $this->state = 3; + } + } + + return $this->state; + } + + /** + * Get the given child's state, possibly adjusted by override rules + * + * @param Node|string $child + * @return int + */ + public function getChildState($child) + { + if (! $child instanceof Node) { + $child = $this->getChildByName($child); + } + + $childName = $child->getName(); + $childState = $child->getState(); + if (! isset($this->stateOverrides[$childName][$childState])) { + return $childState; + } + + return $this->stateOverrides[$childName][$childState]; + } + + public function getHtmlId() + { + return 'businessprocess-' . preg_replace('/[\r\n\t\s]/', '_', $this->getName()); + } + + protected function invertSortingState($state) + { + return self::$sortStateInversionMap[$state >> self::SHIFT_FLAGS] << self::SHIFT_FLAGS; + } + + /** + * @return $this + */ + public function reCalculateState() + { + $bp = $this->getBpConfig(); + + $sort_states = array(); + $lastStateChange = 0; + + if ($this->isEmpty()) { + // TODO: delegate this to operators, should mostly fail + $this->setState(self::NODE_EMPTY); + return $this; + } + + foreach ($this->getChildren() as $child) { + $bp->beginLoopDetection($this->name); + if ($child instanceof MonitoredNode && $child->isMissing()) { + if ($child instanceof HostNode) { + $child->setState(self::ICINGA_UNREACHABLE); + } else { + $child->setState(self::ICINGA_UNKNOWN); + } + + $child->setMissing(); + } + $sort_states[] = $child->getSortingState($this->getChildState($child)); + $lastStateChange = max($lastStateChange, $child->getLastStateChange()); + $bp->endLoopDetection($this->name); + } + + $this->setLastStateChange($lastStateChange); + + switch ($this->getOperator()) { + case self::OP_AND: + $sort_state = max($sort_states); + break; + case self::OP_NOT: + $sort_state = $this->invertSortingState(max($sort_states)); + break; + case self::OP_OR: + $sort_state = min($sort_states); + break; + case self::OP_XOR: + $actualGood = 0; + foreach ($sort_states as $s) { + if ($this->sortStateTostate($s) === self::ICINGA_OK) { + $actualGood++; + } + } + + if ($actualGood === 1) { + $this->state = self::ICINGA_OK; + } else { + $this->state = self::ICINGA_CRITICAL; + } + + return $this; + case self::OP_DEGRADED: + $maxState = max($sort_states); + $flags = $maxState & 0xf; + + $maxIcingaState = $this->sortStateTostate($maxState); + $warningState = ($this->stateToSortState(self::ICINGA_WARNING) << self::SHIFT_FLAGS) + $flags; + + $sort_state = ($maxIcingaState === self::ICINGA_CRITICAL) ? $warningState : $maxState; + break; + default: + // MIN: + $sort_state = 3 << self::SHIFT_FLAGS; + + if (count($sort_states) >= $this->operator) { + $actualGood = 0; + foreach ($sort_states as $s) { + if (($s >> self::SHIFT_FLAGS) === self::ICINGA_OK) { + $actualGood++; + } + } + + if ($actualGood >= $this->operator) { + // condition is fulfilled + $sort_state = self::ICINGA_OK; + } else { + // worst state if not fulfilled + $sort_state = max($sort_states); + } + } + } + if ($sort_state & self::FLAG_DOWNTIME) { + $this->setDowntime(true); + } + if ($sort_state & self::FLAG_ACK) { + $this->setAck(true); + } + + $this->state = $this->sortStateTostate($sort_state); + return $this; + } + + public function checkForLoops() + { + $bp = $this->getBpConfig(); + foreach ($this->getChildren() as $child) { + $bp->beginLoopDetection($this->name); + if ($child instanceof BpNode) { + $child->checkForLoops(); + } + $bp->endLoopDetection($this->name); + } + + return $this; + } + + public function setDisplay($display) + { + $this->display = (int) $display; + return $this; + } + + public function getDisplay() + { + return $this->display; + } + + public function setChildNames($names) + { + $this->childNames = $names; + $this->children = null; + return $this; + } + + public function hasChildren($filter = null) + { + $childNames = $this->getChildNames(); + return !empty($childNames); + } + + public function getChildNames() + { + return $this->childNames; + } + + public function getChildren($filter = null) + { + if ($this->children === null) { + $this->children = []; + foreach ($this->getChildNames() as $name) { + $this->children[$name] = $this->getBpConfig()->getNode($name); + $this->children[$name]->addParent($this); + } + } + + return $this->children; + } + + /** + * return BpNode[] + */ + public function getChildBpNodes() + { + $children = array(); + + foreach ($this->getChildren() as $name => $child) { + if ($child instanceof BpNode) { + $children[$name] = $child; + } + } + + return $children; + } + + /** + * @param $childName + * @return Node + * @throws NotFoundError + */ + public function getChildByName($childName) + { + foreach ($this->getChildren() as $name => $child) { + if ($name === $childName) { + return $child; + } + } + + throw new NotFoundError('Trying to get missing child %s', $childName); + } + + protected function assertNumericOperator() + { + if (! is_numeric($this->getOperator())) { + throw new ConfigurationError('Got invalid operator: %s', $this->operator); + } + } + + public function operatorHtml() + { + switch ($this->getOperator()) { + case self::OP_AND: + return 'AND'; + case self::OP_OR: + return 'OR'; + case self::OP_XOR: + return 'XOR'; + case self::OP_NOT: + return 'NOT'; + case self::OP_DEGRADED: + return 'DEG'; + default: + // MIN + $this->assertNumericOperator(); + return 'min:' . $this->operator; + } + } + + public function getIcon(): Icon + { + $this->icon = $this->hasParents() ? 'cubes' : 'sitemap'; + return parent::getIcon(); + } +} diff --git a/library/Businessprocess/Common/Sort.php b/library/Businessprocess/Common/Sort.php new file mode 100644 index 0000000..4728af3 --- /dev/null +++ b/library/Businessprocess/Common/Sort.php @@ -0,0 +1,158 @@ +<?php +// Icinga Business Process Modelling | (c) 2023 Icinga GmbH | GPLv2 + +namespace Icinga\Module\Businessprocess\Common; + +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\Node; +use InvalidArgumentException; +use ipl\Stdlib\Str; + +trait Sort +{ + /** @var ?string Current sort specification */ + protected $sort; + + /** @var ?callable Actual sorting function */ + protected $sortFn; + + /** + * Get the sort specification + * + * @return ?string + */ + public function getSort(): ?string + { + return $this->sort; + } + + /** + * Set the sort specification + * + * @param ?string $sort + * + * @return $this + * + * @throws InvalidArgumentException When sorting according to the specified specification is not possible + */ + public function setSort(?string $sort): self + { + if (empty($sort)) { + return $this; + } + + list($sortBy, $direction) = Str::symmetricSplit($sort, ' ', 2, 'asc'); + + switch ($sortBy) { + case 'manual': + if ($direction === 'asc') { + $this->sortFn = function (array &$nodes) { + $firstNode = reset($nodes); + if ($firstNode instanceof BpNode && $firstNode->getDisplay() > 0) { + $nodes = self::applyManualSorting($nodes); + } + + // Child nodes don't need to be ordered in this case, their implicit order is significant + }; + } else { + $this->sortFn = function (array &$nodes) { + $firstNode = reset($nodes); + if ($firstNode instanceof BpNode && $firstNode->getDisplay() > 0) { + uasort($nodes, function (BpNode $a, BpNode $b) { + return $b->getDisplay() <=> $a->getDisplay(); + }); + } else { + $nodes = array_reverse($nodes); + } + }; + } + + break; + case 'display_name': + if ($direction === 'asc') { + $this->sortFn = function (array &$nodes) { + uasort($nodes, function (Node $a, Node $b) { + return strnatcasecmp( + $a->getAlias() ?? $a->getName(), + $b->getAlias() ?? $b->getName() + ); + }); + }; + } else { + $this->sortFn = function (array &$nodes) { + uasort($nodes, function (Node $a, Node $b) { + return strnatcasecmp( + $b->getAlias() ?? $b->getName(), + $a->getAlias() ?? $a->getName() + ); + }); + }; + } + + break; + case 'state': + if ($direction === 'asc') { + $this->sortFn = function (array &$nodes) { + uasort($nodes, function (Node $a, Node $b) { + return $a->getSortingState() <=> $b->getSortingState(); + }); + }; + } else { + $this->sortFn = function (array &$nodes) { + uasort($nodes, function (Node $a, Node $b) { + return $b->getSortingState() <=> $a->getSortingState(); + }); + }; + } + + break; + default: + throw new InvalidArgumentException(sprintf( + "Can't sort by %s. It's only possible to sort by manual order, display_name or state", + $sortBy + )); + } + + $this->sort = $sort; + + return $this; + } + + /** + * Sort the given nodes as specified by {@see setSort()} + * + * If {@see setSort()} has not been called yet, the default sort specification is used + * + * @param array $nodes + * + * @return array + */ + public function sort(array $nodes): array + { + if (empty($nodes)) { + return $nodes; + } + + if ($this->sortFn !== null) { + call_user_func_array($this->sortFn, [&$nodes]); + } + + return $nodes; + } + + /** + * Apply manual sort order on the given process nodes + * + * @param array $bpNodes + * + * @return array + */ + public static function applyManualSorting(array $bpNodes): array + { + uasort($bpNodes, function (BpNode $a, BpNode $b) { + return $a->getDisplay() <=> $b->getDisplay(); + }); + + return $bpNodes; + } +} diff --git a/library/Businessprocess/Director/ShipConfigFiles.php b/library/Businessprocess/Director/ShipConfigFiles.php new file mode 100644 index 0000000..17b9e1f --- /dev/null +++ b/library/Businessprocess/Director/ShipConfigFiles.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Businessprocess\Director; + +use Icinga\Module\Director\Hook\ShipConfigFilesHook; +use Icinga\Module\Businessprocess\Storage\LegacyStorage; + +class ShipConfigFiles extends ShipConfigFilesHook +{ + public function fetchFiles() + { + $files = array(); + + $storage = LegacyStorage::getInstance(); + + foreach ($storage->listProcesses() as $name => $title) { + $files['processes/' . $name . '.bp'] = $storage->getSource($name); + } + + return $files; + } +} diff --git a/library/Businessprocess/Exception/ModificationError.php b/library/Businessprocess/Exception/ModificationError.php new file mode 100644 index 0000000..430d513 --- /dev/null +++ b/library/Businessprocess/Exception/ModificationError.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Businessprocess\Exception; + +use Icinga\Exception\IcingaException; + +class ModificationError extends IcingaException +{ +} diff --git a/library/Businessprocess/Exception/NestingError.php b/library/Businessprocess/Exception/NestingError.php new file mode 100644 index 0000000..89cbf81 --- /dev/null +++ b/library/Businessprocess/Exception/NestingError.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Businessprocess\Exception; + +use Icinga\Exception\IcingaException; + +class NestingError extends IcingaException +{ +} diff --git a/library/Businessprocess/HostNode.php b/library/Businessprocess/HostNode.php new file mode 100644 index 0000000..df25630 --- /dev/null +++ b/library/Businessprocess/HostNode.php @@ -0,0 +1,64 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Module\Businessprocess\Web\Url; + +class HostNode extends MonitoredNode +{ + protected $sortStateToStateMap = array( + 4 => self::ICINGA_DOWN, + 3 => self::ICINGA_UNREACHABLE, + 1 => self::ICINGA_PENDING, + 0 => self::ICINGA_UP + ); + + protected $stateToSortStateMap = array( + self::ICINGA_PENDING => 1, + self::ICINGA_UNREACHABLE => 3, + self::ICINGA_DOWN => 4, + self::ICINGA_UP => 0, + ); + + protected $stateNames = array( + 'UP', + 'DOWN', + 'UNREACHABLE', + 99 => 'PENDING' + ); + + protected $hostname; + + protected $className = 'host'; + + protected $icon = 'laptop'; + + public function __construct($object) + { + $this->name = BpConfig::joinNodeName($object->hostname, 'Hoststatus'); + $this->hostname = $object->hostname; + if (isset($object->state)) { + $this->setState($object->state); + } else { + $this->setState(0)->setMissing(); + } + } + + public function getHostname() + { + return $this->hostname; + } + + public function getUrl() + { + $params = array( + 'host' => $this->getHostname(), + ); + + if ($this->getBpConfig()->hasBackendName()) { + $params['backend'] = $this->getBpConfig()->getBackendName(); + } + + return Url::fromPath('businessprocess/host/show', $params); + } +} diff --git a/library/Businessprocess/IcingaDbObject.php b/library/Businessprocess/IcingaDbObject.php new file mode 100644 index 0000000..cad459f --- /dev/null +++ b/library/Businessprocess/IcingaDbObject.php @@ -0,0 +1,94 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Common\Database as IcingadbDatabase; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Sql\Connection as IcingaDbConnection; +use ipl\Web\Filter\QueryString; + +class IcingaDbObject +{ + use IcingadbDatabase; + + use Auth; + + /** @var BpConfig */ + protected $config; + + /** @var IcingaDbConnection */ + protected $conn; + + public function __construct() + { + $this->conn = $this->getDb(); + } + + public function fetchHosts($filter = null) + { + + $hosts = Host::on($this->conn); + + if ($filter !== null) { + $filterQuery = QueryString::parse($filter); + + $hosts->filter($filterQuery); + } + + $hosts->orderBy('host.name'); + + $this->applyIcingaDbRestrictions($hosts); + + return $hosts; + } + + public function fetchServices($filter) + { + $services = Service::on($this->conn) + ->with('host'); + + if ($filter !== null) { + $filterQuery = QueryString::parse($filter); + + $services->filter($filterQuery); + } + + $services->orderBy('service.name'); + + $this->applyIcingaDbRestrictions($services); + + return $services; + } + + public function yieldHostnames($filter = null) + { + foreach ($this->fetchHosts($filter) as $host) { + yield $host->name; + } + } + + public function yieldServicenames($host) + { + $filter = "host.name=$host"; + + foreach ($this->fetchServices($filter) as $service) { + yield $service->name; + } + } + + public static function applyIcingaDbRestrictions($query) + { + $object = new self; + $object->applyRestrictions($query); + + return $object; + } + + public static function fetchDb() + { + $object = new self; + return $object->getDb(); + } +} diff --git a/library/Businessprocess/ImportedNode.php b/library/Businessprocess/ImportedNode.php new file mode 100644 index 0000000..a0eb6b1 --- /dev/null +++ b/library/Businessprocess/ImportedNode.php @@ -0,0 +1,139 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Exception; + +class ImportedNode extends BpNode +{ + /** @var BpConfig */ + protected $parentBp; + + /** @var string */ + protected $configName; + + /** @var string */ + protected $nodeName; + + /** @var BpNode */ + protected $importedNode; + + /** @var string */ + protected $className = 'process subtree'; + + /** @var string */ + protected $icon = 'download'; + + public function __construct(BpConfig $parentBp, $object) + { + $this->parentBp = $parentBp; + $this->configName = $object->configName; + $this->nodeName = BpConfig::escapeName($object->node); + + parent::__construct((object) [ + 'name' => '@' . $this->configName . ':' . $this->nodeName, + 'operator' => null, + 'child_names' => null + ]); + } + + /** + * @return string + */ + public function getConfigName() + { + return $this->configName; + } + + /** + * @return string + */ + public function getNodeName() + { + return $this->nodeName; + } + + public function getIdentifier() + { + return $this->getName(); + } + + public function getBpConfig() + { + if ($this->bp === null) { + $this->bp = $this->parentBp->getImportedConfig($this->configName); + } + + return $this->bp; + } + + public function getAlias() + { + return $this->importedNode()->getAlias(); + } + + public function getOperator() + { + if ($this->operator === null) { + $this->operator = $this->importedNode()->getOperator(); + } + + return $this->operator; + } + + public function getChildNames() + { + if ($this->childNames === null) { + $this->childNames = $this->importedNode()->getChildNames(); + } + + return $this->childNames; + } + + public function isMissing() + { + if ($this->missing === null && $this->getBpConfig()->isFaulty()) { + $this->missing = true; + } + + return parent::isMissing(); + } + + /** + * @return BpNode + */ + protected function importedNode() + { + if ($this->importedNode === null) { + try { + $this->importedNode = $this->getBpConfig()->getBpNode($this->nodeName); + } catch (Exception $e) { + return $this->createFailedNode($e); + } + } + + return $this->importedNode; + } + + /** + * @param Exception $e + * + * @return BpNode + */ + protected function createFailedNode(Exception $e) + { + $this->parentBp->addError($e->getMessage()); + $node = new BpNode((object) array( + 'name' => $this->getName(), + 'operator' => '&', + 'child_names' => [] + )); + $node->setBpConfig($this->getBpConfig()); + $node->setState(2); + $node->setMissing() + ->setDowntime(false) + ->setAck(false); + + return $node; + } +} diff --git a/library/Businessprocess/Metadata.php b/library/Businessprocess/Metadata.php new file mode 100644 index 0000000..b640fb8 --- /dev/null +++ b/library/Businessprocess/Metadata.php @@ -0,0 +1,264 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Exception\ProgrammingError; +use Icinga\User; + +class Metadata +{ + /** @var string Configuration name */ + protected $name; + + protected $properties = array( + 'Title' => null, + 'Description' => null, + 'Owner' => null, + 'AllowedUsers' => null, + 'AllowedGroups' => null, + 'AllowedRoles' => null, + 'AddToMenu' => null, + 'Backend' => null, + 'Statetype' => null, + 'ManualOrder' => null, + // 'SLAHosts' => null + ); + + public function __construct($name) + { + $this->name = $name; + } + + public function getTitle() + { + if ($this->has('Title')) { + return $this->get('Title'); + } else { + return $this->name; + } + } + + public function getExtendedTitle() + { + $title = $this->getTitle(); + + if ($title === $this->name) { + return $title; + } else { + return sprintf('%s (%s)', $title, $this->name); + } + } + + public function getProperties() + { + return $this->properties; + } + + public function hasKey($key) + { + return array_key_exists($key, $this->properties); + } + + public function get($key, $default = null) + { + $this->assertKeyExists($key); + if ($this->properties[$key] === null) { + return $default; + } + + return $this->properties[$key]; + } + + public function set($key, $value) + { + $this->assertKeyExists($key); + $this->properties[$key] = $value; + + return $this; + } + + public function isNull($key) + { + return null === $this->get($key); + } + + public function has($key) + { + return null !== $this->get($key); + } + + protected function assertKeyExists($key) + { + if (! $this->hasKey($key)) { + throw new ProgrammingError('Trying to access invalid header key: %s', $key); + } + + return $this; + } + + public function hasRestrictions() + { + return ! ( + $this->isNull('AllowedUsers') + && $this->isNull('AllowedGroups') + && $this->isNull('AllowedRoles') + ); + } + + protected function getAuth() + { + return Auth::getInstance(); + } + + public function canModify(Auth $auth = null) + { + if ($auth === null) { + if (Icinga::app()->isCli()) { + return true; + } else { + $auth = $this->getAuth(); + } + } + + return $this->canRead($auth) && ( + $auth->hasPermission('businessprocess/modify') + || $this->ownerIs($auth->getUser()->getUsername()) + ); + } + + public function canRead(Auth $auth = null) + { + if ($auth === null) { + if (Icinga::app()->isCli()) { + return true; + } else { + $auth = $this->getAuth(); + } + } + + if ($auth->hasPermission('businessprocess/showall')) { + return true; + } + + $prefixes = $auth->getRestrictions('businessprocess/prefix'); + if (! empty($prefixes)) { + if (! $this->nameIsPrefixedWithOneOf($prefixes)) { + return false; + } + } + + if (! $this->hasRestrictions()) { + return true; + } + + if (! $auth->isAuthenticated()) { + return false; + } + + return $this->userCanRead($auth->getUser()); + } + + public function nameIsPrefixedWithOneOf(array $prefixes) + { + foreach ($prefixes as $prefix) { + if (substr($this->name, 0, strlen($prefix)) === $prefix) { + return true; + } + } + + return false; + } + + protected function userCanRead(User $user) + { + $username = $user->getUsername(); + + return $this->ownerIs($username) + || $this->isInAllowedUserList($username) + || $this->isMemberOfAllowedGroups($user) + || $this->hasOneOfTheAllowedRoles($user); + } + + public function ownerIs($username) + { + return $this->get('Owner') === $username; + } + + public function listAllowedUsers() + { + // TODO: $this->get('AllowedUsers', array()); + $list = $this->get('AllowedUsers'); + if ($list === null) { + return array(); + } else { + return $this->splitCommaSeparated($list); + } + } + + public function listAllowedGroups() + { + $list = $this->get('AllowedGroups'); + if ($list === null) { + return array(); + } else { + return $this->splitCommaSeparated($list); + } + } + + public function listAllowedRoles() + { + $list = $this->get('AllowedRoles'); + if ($list === null) { + return array(); + } else { + return $this->splitCommaSeparated($list); + } + } + + public function isInAllowedUserList($username) + { + foreach ($this->listAllowedUsers() as $allowedUser) { + if ($username === $allowedUser) { + return true; + } + } + + return false; + } + + public function isMemberOfAllowedGroups(User $user) + { + foreach ($this->listAllowedGroups() as $group) { + if ($user->isMemberOf($group)) { + return true; + } + } + + return false; + } + + public function hasOneOfTheAllowedRoles(User $user) + { + foreach ($this->listAllowedRoles() as $roleName) { + foreach ($user->getRoles() as $role) { + if ($role->getName() === $roleName) { + return true; + } + } + } + + return false; + } + + public function isManuallyOrdered() + { + return $this->get('ManualOrder') === 'yes'; + } + + protected function splitCommaSeparated($string) + { + return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY); + } +} diff --git a/library/Businessprocess/Modification/NodeAction.php b/library/Businessprocess/Modification/NodeAction.php new file mode 100644 index 0000000..b5baa5d --- /dev/null +++ b/library/Businessprocess/Modification/NodeAction.php @@ -0,0 +1,179 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Exception\ModificationError; +use Icinga\Module\Businessprocess\Node; +use Icinga\Exception\ProgrammingError; + +/** + * Abstract NodeAction class + * + * Every instance of a NodeAction represents a single applied change. Changes are pushed to + * a stack and consumed from there. When persisted, NodeActions are serialized with their name, + * node name and optionally additional properties according preserveProperties. For each property + * that should be preserved, getter and setter methods have to be defined. + * + * @package Icinga\Module\Businessprocess + */ +abstract class NodeAction +{ + /** @var string Name of this action (currently create, modify, remove) */ + protected $actionName; + + /** @var string Name of the node this action applies to */ + protected $nodeName; + + /** @var array Properties which should be preserved when serializing this action */ + protected $preserveProperties = array(); + + /** + * NodeAction constructor. + * + * @param Node|string $node + */ + public function __construct($node = null) + { + if ($node !== null) { + $this->nodeName = (string) $node; + } + } + + /** + * Every NodeAction must be able to apply itself to a BusinessProcess + * + * @param BpConfig $config + * @return mixed + */ + abstract public function applyTo(BpConfig $config); + + /** + * Every NodeAction must be able to tell whether it can be applied to a BusinessProcess + * + * @param BpConfig $config + * + * @throws ModificationError + * + * @return bool + */ + abstract public function appliesTo(BpConfig $config); + + /** + * The name of the node this modification applies to + * + * @return string + */ + public function getNodeName() + { + return $this->nodeName; + } + + public function hasNode() + { + return $this->nodeName !== null; + } + + /** + * Whether this is an instance of a given action name + * + * @param string $actionName + * @return bool + */ + public function is($actionName) + { + return $this->getActionName() === $actionName; + } + + /** + * Throw a ModificationError + * + * @param string $msg + * @param mixed ... + * + * @throws ModificationError + */ + protected function error($msg) + { + $error = ModificationError::create(func_get_args()); + /** @var ModificationError $error */ + throw $error; + } + + /** + * Create an instance of a given actionName for a specific Node + * + * @param string $actionName + * @param string $nodeName + * + * @return static + */ + public static function create($actionName, $nodeName) + { + $className = __NAMESPACE__ . '\\Node' . ucfirst($actionName) . 'Action'; + + return new $className($nodeName); + } + + /** + * Returns a JSON-encoded serialized NodeAction + * + * @return string + */ + public function serialize() + { + $object = (object) array( + 'actionName' => $this->getActionName(), + 'nodeName' => $this->getNodeName(), + 'properties' => array() + ); + + foreach ($this->preserveProperties as $key) { + $func = 'get' . ucfirst($key); + $object->properties[$key] = $this->$func(); + } + + return json_encode($object); + } + + /** + * Decodes a JSON-serialized NodeAction and returns an object instance + * + * @param $string + * @return NodeAction + */ + public static function unSerialize($string) + { + $object = json_decode($string, true); + $action = self::create($object['actionName'], $object['nodeName']); + + foreach ($object['properties'] as $key => $val) { + $func = 'set' . ucfirst($key); + $action->$func($val); + } + + return $action; + } + + /** + * Returns the defined action name or determines such from the class name + * + * @return string The action name + * + * @throws ProgrammingError when no such class exists + */ + public function getActionName() + { + if ($this->actionName === null) { + if (! preg_match('/\\\Node(\w+)Action$/', get_class($this), $m)) { + throw new ProgrammingError( + '"%s" is not a NodeAction class', + get_class($this) + ); + } + $this->actionName = lcfirst($m[1]); + } + + return $this->actionName; + } +} diff --git a/library/Businessprocess/Modification/NodeAddChildrenAction.php b/library/Businessprocess/Modification/NodeAddChildrenAction.php new file mode 100644 index 0000000..162c380 --- /dev/null +++ b/library/Businessprocess/Modification/NodeAddChildrenAction.php @@ -0,0 +1,74 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; + +class NodeAddChildrenAction extends NodeAction +{ + protected $children = array(); + + protected $preserveProperties = array('children'); + + /** + * @inheritdoc + */ + public function appliesTo(BpConfig $config) + { + $name = $this->getNodeName(); + + if (! $config->hasBpNode($name)) { + $this->error('Process "%s" not found', $name); + } + + return true; + } + + /** + * @inheritdoc + */ + public function applyTo(BpConfig $config) + { + $node = $config->getBpNode($this->getNodeName()); + + foreach ($this->children as $name) { + if (! $config->hasNode($name) || $config->getNode($name)->getBpConfig()->getName() !== $config->getName()) { + [$prefix, $suffix] = BpConfig::splitNodeName($name); + if ($suffix !== null) { + if ($suffix === 'Hoststatus') { + $config->createHost($prefix); + } else { + $config->createService($prefix, $suffix); + } + } elseif ($name[0] === '@' && strpos($name, ':') !== false) { + list($configName, $nodeName) = preg_split('~:\s*~', substr($name, 1), 2); + $config->createImportedNode($configName, $nodeName); + } + } + $node->addChild($config->getNode($name)); + } + + return $this; + } + + /** + * @param array|string $children + * @return $this + */ + public function setChildren($children) + { + if (is_string($children)) { + $children = array($children); + } + $this->children = $children; + return $this; + } + + /** + * @return array + */ + public function getChildren() + { + return $this->children; + } +} diff --git a/library/Businessprocess/Modification/NodeApplyManualOrderAction.php b/library/Businessprocess/Modification/NodeApplyManualOrderAction.php new file mode 100644 index 0000000..4ad53e0 --- /dev/null +++ b/library/Businessprocess/Modification/NodeApplyManualOrderAction.php @@ -0,0 +1,35 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Common\Sort; + +class NodeApplyManualOrderAction extends NodeAction +{ + use Sort; + + public function appliesTo(BpConfig $config) + { + return $config->getMetadata()->get('ManualOrder') !== 'yes'; + } + + public function applyTo(BpConfig $config) + { + $i = 0; + foreach ($config->getBpNodes() as $name => $node) { + if ($node->getDisplay() > 0) { + $node->setDisplay(++$i); + } + + if ($node->hasChildren()) { + $node->setChildNames(array_keys( + $this->setSort('display_name asc') + ->sort($node->getChildren()) + )); + } + } + + $config->getMetadata()->set('ManualOrder', 'yes'); + } +} diff --git a/library/Businessprocess/Modification/NodeCopyAction.php b/library/Businessprocess/Modification/NodeCopyAction.php new file mode 100644 index 0000000..80d781b --- /dev/null +++ b/library/Businessprocess/Modification/NodeCopyAction.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Common\Sort; + +class NodeCopyAction extends NodeAction +{ + use Sort; + + /** + * @param BpConfig $config + * @return bool + */ + public function appliesTo(BpConfig $config) + { + $name = $this->getNodeName(); + + if (! $config->hasBpNode($name)) { + $this->error('Process "%s" not found', $name); + } + + if ($config->hasRootNode($name)) { + $this->error('A toplevel node with name "%s" already exists', $name); + } + + return true; + } + + /** + * @param BpConfig $config + */ + public function applyTo(BpConfig $config) + { + $name = $this->getNodeName(); + + $display = 1; + if ($config->getMetadata()->isManuallyOrdered()) { + $rootNodes = self::applyManualSorting($config->getRootNodes()); + $display = end($rootNodes)->getDisplay() + 1; + } + + $config->addRootNode($name) + ->getBpNode($name) + ->setDisplay($display); + } +} diff --git a/library/Businessprocess/Modification/NodeCreateAction.php b/library/Businessprocess/Modification/NodeCreateAction.php new file mode 100644 index 0000000..167d3bc --- /dev/null +++ b/library/Businessprocess/Modification/NodeCreateAction.php @@ -0,0 +1,129 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\Node; + +class NodeCreateAction extends NodeAction +{ + /** @var string */ + protected $parentName; + + /** @var array */ + protected $properties = array(); + + /** @var array */ + protected $preserveProperties = array('parentName', 'properties'); + + /** + * @param Node $name + */ + public function setParent(Node $name) + { + $this->parentName = $name->getName(); + } + + /** + * @return bool + */ + public function hasParent() + { + return $this->parentName !== null; + } + + /** + * @return string + */ + public function getParentName() + { + return $this->parentName; + } + + /** + * @param string $name + */ + public function setParentName($name) + { + $this->parentName = $name; + } + + /** + * @return array + */ + public function getProperties() + { + return $this->properties; + } + + /** + * @param array $properties + * @return $this + */ + public function setProperties($properties) + { + $this->properties = (array) $properties; + return $this; + } + + /** + * @inheritdoc + */ + public function appliesTo(BpConfig $config) + { + $name = $this->getNodeName(); + if ($config->hasNode($name)) { + $this->error('A node with name "%s" already exists', $name); + } + + $parent = $this->getParentName(); + if ($parent !== null && !$config->hasBpNode($parent)) { + $this->error('Parent process "%s" missing', $parent); + } + + return true; + } + + /** + * @inheritdoc + */ + public function applyTo(BpConfig $config) + { + $name = $this->getNodeName(); + + $properties = array( + 'name' => $name, + 'operator' => $this->properties['operator'], + ); + if (array_key_exists('childNames', $this->properties)) { + $properties['child_names'] = $this->properties['childNames']; + } else { + $properties['child_names'] = array(); + } + $node = new BpNode((object) $properties); + $node->setBpConfig($config); + + foreach ($this->getProperties() as $key => $val) { + if ($key === 'parentName') { + $config->getBpNode($val)->addChild($node); + continue; + } + $func = 'set' . ucfirst($key); + $node->$func($val); + } + + if ($node->getDisplay() > 1) { + $i = $node->getDisplay(); + foreach ($config->getRootNodes() as $_ => $rootNode) { + if ($rootNode->getDisplay() >= $node->getDisplay()) { + $rootNode->setDisplay(++$i); + } + } + } + + $config->addNode($name, $node); + + return $node; + } +} diff --git a/library/Businessprocess/Modification/NodeModifyAction.php b/library/Businessprocess/Modification/NodeModifyAction.php new file mode 100644 index 0000000..1b33094 --- /dev/null +++ b/library/Businessprocess/Modification/NodeModifyAction.php @@ -0,0 +1,121 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Node; + +class NodeModifyAction extends NodeAction +{ + protected $properties = array(); + + protected $formerProperties = array(); + + protected $preserveProperties = array('formerProperties', 'properties'); + + /** + * Set properties for a specific node + * + * Can be called multiple times + * + * @param Node $node + * @param array $properties + * + * @return $this + */ + public function setNodeProperties(Node $node, array $properties) + { + foreach (array_keys($properties) as $key) { + $this->properties[$key] = $properties[$key]; + + if (array_key_exists($key, $this->formerProperties)) { + continue; + } + + $func = 'get' . ucfirst($key); + $this->formerProperties[$key] = $node->$func(); + } + + return $this; + } + + /** + * @inheritdoc + */ + public function appliesTo(BpConfig $config) + { + $name = $this->getNodeName(); + + if (! $config->hasNode($name)) { + $this->error('Node "%s" not found', $name); + } + + $node = $config->getNode($name); + + foreach ($this->properties as $key => $val) { + $currentVal = $node->{'get' . ucfirst($key)}(); + if ($this->formerProperties[$key] !== $currentVal) { + $this->error( + 'Property %s of node "%s" changed its value from "%s" to "%s"', + $key, + $name, + $this->formerProperties[$key], + $currentVal + ); + } + } + + return true; + } + + /** + * @inheritdoc + */ + public function applyTo(BpConfig $config) + { + $node = $config->getNode($this->getNodeName()); + + foreach ($this->properties as $key => $val) { + $func = 'set' . ucfirst($key); + $node->$func($val); + } + + return $this; + } + + /** + * @param $properties + * @return $this + */ + public function setProperties($properties) + { + $this->properties = $properties; + return $this; + } + + /** + * @param $properties + * @return $this + */ + public function setFormerProperties($properties) + { + $this->formerProperties = $properties; + return $this; + } + + /** + * @return array + */ + public function getProperties() + { + return $this->properties; + } + + /** + * @return array + */ + public function getFormerProperties() + { + return $this->formerProperties; + } +} diff --git a/library/Businessprocess/Modification/NodeMoveAction.php b/library/Businessprocess/Modification/NodeMoveAction.php new file mode 100644 index 0000000..4c4305d --- /dev/null +++ b/library/Businessprocess/Modification/NodeMoveAction.php @@ -0,0 +1,227 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\Common\Sort; + +class NodeMoveAction extends NodeAction +{ + use Sort; + + /** + * @var string + */ + protected $parent; + + /** + * @var string + */ + protected $newParent; + + /** + * @var int + */ + protected $from; + + /** + * @var int + */ + protected $to; + + protected $preserveProperties = ['parent', 'newParent', 'from', 'to']; + + public function setParent($name) + { + $this->parent = $name; + } + + public function getParent() + { + return $this->parent; + } + + public function setNewParent($name) + { + $this->newParent = $name; + } + + public function getNewParent() + { + return $this->newParent; + } + + public function setFrom($from) + { + $this->from = (int) $from; + } + + public function getFrom() + { + return $this->from; + } + + public function setTo($to) + { + $this->to = (int) $to; + } + + public function getTo() + { + return $this->to; + } + + public function appliesTo(BpConfig $config) + { + if (! $config->getMetadata()->isManuallyOrdered()) { + $this->error('Process configuration is not manually ordered yet'); + } + + $name = $this->getNodeName(); + if ($this->parent !== null) { + if (! $config->hasBpNode($this->parent)) { + $this->error('Parent process "%s" missing', $this->parent); + } + $parent = $config->getBpNode($this->parent); + if (! $parent->hasChild($name)) { + $this->error('Node "%s" not found in process "%s"', $name, $this->parent); + } + + $nodes = $parent->getChildNames(); + if (! isset($nodes[$this->from]) || $nodes[$this->from] !== $name) { + $reversedNodes = array_reverse($nodes); // The user may have reversed the sort direction + if (! isset($reversedNodes[$this->from]) || $reversedNodes[$this->from] !== $name) { + $this->error('Node "%s" not found at position %d', $name, $this->from); + } else { + $this->from = array_search($reversedNodes[$this->from], $nodes, true); + $this->to = array_search($reversedNodes[$this->to], $nodes, true); + } + } + } else { + if (! $config->hasRootNode($name)) { + $this->error('Toplevel process "%s" not found', $name); + } + + $nodes = array_keys(self::applyManualSorting($config->getRootNodes())); + if (! isset($nodes[$this->from]) || $nodes[$this->from] !== $name) { + $reversedNodes = array_reverse($nodes); // The user may have reversed the sort direction + if (! isset($reversedNodes[$this->from]) || $reversedNodes[$this->from] !== $name) { + $this->error('Toplevel process "%s" not found at position %d', $name, $this->from); + } else { + $this->from = array_search($reversedNodes[$this->from], $nodes, true); + $this->to = array_search($reversedNodes[$this->to], $nodes, true); + } + } + } + + if ($this->parent !== $this->newParent) { + if ($this->newParent !== null) { + if (! $config->hasBpNode($this->newParent)) { + $this->error('New parent process "%s" missing', $this->newParent); + } elseif ($config->getBpNode($this->newParent)->hasChild($name)) { + $this->error( + 'New parent process "%s" already has a node with the name "%s"', + $this->newParent, + $name + ); + } + + $childrenCount = $config->getBpNode($this->newParent)->countChildren(); + if ($this->to > 0 && $childrenCount < $this->to) { + $this->error( + 'New parent process "%s" has not enough children. Target position %d out of range', + $this->newParent, + $this->to + ); + } + } else { + if ($config->hasRootNode($name)) { + $this->error('Process "%s" is already a toplevel process', $name); + } + + $childrenCount = $config->countChildren(); + if ($this->to > 0 && $childrenCount < $this->to) { + $this->error( + 'Process configuration has not enough toplevel processes. Target position %d out of range', + $this->to + ); + } + } + } + + return true; + } + + public function applyTo(BpConfig $config) + { + $name = $this->getNodeName(); + if ($this->parent !== null) { + $nodes = $config->getBpNode($this->parent)->getChildren(); + } else { + $nodes = self::applyManualSorting($config->getRootNodes()); + } + + $node = $nodes[$name]; + $nodes = array_merge( + array_slice($nodes, 0, $this->from, true), + array_slice($nodes, $this->from + 1, null, true) + ); + if ($this->parent === $this->newParent) { + $nodes = array_merge( + array_slice($nodes, 0, $this->to, true), + [$name => $node], + array_slice($nodes, $this->to, null, true) + ); + } else { + if ($this->newParent !== null) { + $newNodes = $config->getBpNode($this->newParent)->getChildren(); + } else { + $newNodes = self::applyManualSorting($config->getRootNodes()); + } + + $newNodes = array_merge( + array_slice($newNodes, 0, $this->to, true), + [$name => $node], + array_slice($newNodes, $this->to, null, true) + ); + + if ($this->newParent !== null) { + $config->getBpNode($this->newParent)->setChildNames(array_keys($newNodes)); + } else { + $config->addRootNode($name); + + $i = 0; + foreach ($newNodes as $newName => $newNode) { + /** @var BpNode $newNode */ + if ($newNode->getDisplay() > 0 || $newName === $name) { + $i += 1; + if ($newNode->getDisplay() !== $i) { + $newNode->setDisplay($i); + } + } + } + } + } + + if ($this->parent !== null) { + $config->getBpNode($this->parent)->setChildNames(array_keys($nodes)); + } else { + if ($this->newParent !== null) { + $config->removeRootNode($name); + $node->setDisplay(0); + } + + $i = 0; + foreach ($nodes as $_ => $oldNode) { + /** @var BpNode $oldNode */ + if ($oldNode->getDisplay() > 0) { + $i += 1; + if ($oldNode->getDisplay() !== $i) { + $oldNode->setDisplay($i); + } + } + } + } + } +} diff --git a/library/Businessprocess/Modification/NodeRemoveAction.php b/library/Businessprocess/Modification/NodeRemoveAction.php new file mode 100644 index 0000000..6100146 --- /dev/null +++ b/library/Businessprocess/Modification/NodeRemoveAction.php @@ -0,0 +1,125 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\Node; + +/** + * NodeRemoveAction + * + * Tracks removed nodes + * + * @package Icinga\Module\Businessprocess + */ +class NodeRemoveAction extends NodeAction +{ + protected $preserveProperties = array('parentName'); + + protected $parentName; + + /** + * @param $parentName + * @return $this + */ + public function setParentName($parentName = null) + { + $this->parentName = $parentName; + return $this; + } + + /** + * @return mixed + */ + public function getParentName() + { + return $this->parentName; + } + + /** + * @inheritdoc + */ + public function appliesTo(BpConfig $config) + { + $name = $this->getNodeName(); + $parent = $this->getParentName(); + if ($parent === null) { + if (!$config->hasNode($name)) { + $this->error('Toplevel process "%s" not found', $name); + } + } else { + if (! $config->hasNode($parent)) { + $this->error('Parent process "%s" missing', $parent); + } elseif (! $config->getBpNode($parent)->hasChild($name)) { + $this->error('Node "%s" not found in process "%s"', $name, $parent); + } + } + + return true; + } + + /** + * @inheritdoc + */ + public function applyTo(BpConfig $config) + { + $name = $this->getNodeName(); + $parentName = $this->getParentName(); + $node = $config->getNode($name); + + /** @var ?BpNode $parentBpNode */ + $parentBpNode = $parentName ? $config->getNode($parentName) : null; + $this->updateStateOverrides($node, $parentBpNode); + + if ($parentName === null) { + if (! $config->hasBpNode($name)) { + $config->removeNode($name); + } else { + $oldDisplay = $config->getBpNode($name)->getDisplay(); + $config->removeNode($name); + if ($config->getMetadata()->isManuallyOrdered()) { + foreach ($config->getRootNodes() as $_ => $node) { + $nodeDisplay = $node->getDisplay(); + if ($nodeDisplay > $oldDisplay) { + $node->setDisplay($node->getDisplay() - 1); + } elseif ($nodeDisplay === $oldDisplay) { + break; // Stop immediately to not make things worse ;) + } + } + } + } + } else { + $parent = $config->getBpNode($parentName); + $parent->removeChild($name); + $node->removeParent($parentName); + if (! $node->hasParents()) { + $config->removeNode($name); + } + } + } + + /** + * Update state overrides + * + * @param Node $node + * @param BpNode|null $nodeParent + * + * @return void + */ + private function updateStateOverrides(Node $node, ?BpNode $nodeParent): void + { + $parents = []; + if ($nodeParent !== null) { + $parents = [$nodeParent]; + } else { + $parents = $node->getParents(); + } + + foreach ($parents as $parent) { + $parentStateOverrides = $parent->getStateOverrides(); + unset($parentStateOverrides[$node->getName()]); + $parent->setStateOverrides($parentStateOverrides); + } + } +} diff --git a/library/Businessprocess/Modification/ProcessChanges.php b/library/Businessprocess/Modification/ProcessChanges.php new file mode 100644 index 0000000..9257558 --- /dev/null +++ b/library/Businessprocess/Modification/ProcessChanges.php @@ -0,0 +1,294 @@ +<?php + +namespace Icinga\Module\Businessprocess\Modification; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Node; +use Icinga\Web\Session\SessionNamespace as Session; + +class ProcessChanges +{ + /** @var NodeAction[] */ + protected $changes = array(); + + /** @var Session */ + protected $session; + + /** @var BpConfig */ + protected $config; + + /** @var bool */ + protected $hasBeenModified = false; + + /** @var string Session storage key for this processes changes */ + protected $sessionKey; + + /** + * ProcessChanges constructor. + * + * Direct access is not allowed + */ + private function __construct() + { + } + + /** + * @param BpConfig $bp + * @param Session $session + * + * @return ProcessChanges + */ + public static function construct(BpConfig $bp, Session $session) + { + $key = 'changes.' . $bp->getName(); + $changes = new ProcessChanges(); + $changes->sessionKey = $key; + + if ($actions = $session->get($key)) { + foreach ($actions as $string) { + $changes->push(NodeAction::unSerialize($string)); + } + } + $changes->session = $session; + $changes->config = $bp; + + return $changes; + } + + /** + * @param Node $node + * @param $properties + * + * @return $this + */ + public function modifyNode(Node $node, $properties) + { + $action = new NodeModifyAction($node); + $action->setNodeProperties($node, $properties); + return $this->push($action, true); + } + + /** + * @param Node $node + * @param $properties + * + * @return $this + */ + public function addChildrenToNode($children, Node $node = null) + { + $action = new NodeAddChildrenAction($node); + $action->setChildren($children); + return $this->push($action, true); + } + + /** + * @param Node|string $nodeName + * @param array $properties + * @param Node $parent + * + * @return $this + */ + public function createNode($nodeName, $properties, Node $parent = null) + { + $action = new NodeCreateAction($nodeName); + $action->setProperties($properties); + if ($parent !== null) { + $action->setParent($parent); + } + return $this->push($action, true); + } + + /** + * @param $nodeName + * @return $this + */ + public function copyNode($nodeName) + { + $action = new NodeCopyAction($nodeName); + return $this->push($action, true); + } + + /** + * @param Node $node + * @param string $parentName + * @return $this + */ + public function deleteNode(Node $node, $parentName = null) + { + $action = new NodeRemoveAction($node); + if ($parentName !== null) { + $action->setParentName($parentName); + } + + return $this->push($action, true); + } + + /** + * Move the given node + * + * @param Node $node + * @param int $from + * @param int $to + * @param string $newParent + * @param string $parent + * + * @return $this + */ + public function moveNode(Node $node, $from, $to, $newParent, $parent = null) + { + $action = new NodeMoveAction($node); + $action->setParent($parent); + $action->setNewParent($newParent); + $action->setFrom($from); + $action->setTo($to); + + return $this->push($action, true); + } + + /** + * Apply manual order on the entire bp configuration file + * + * @return $this + */ + public function applyManualOrder() + { + return $this->push(new NodeApplyManualOrderAction(), true); + } + + /** + * Add a new action to the stack + * + * @param NodeAction $change + * @param bool $apply + * + * @return $this + */ + public function push(NodeAction $change, $apply = false) + { + if ($apply && $change->appliesTo($this->config)) { + $change->applyTo($this->config); + } + + $this->changes[] = $change; + $this->hasBeenModified = true; + return $this; + } + + /** + * Get all stacked actions + * + * @return NodeAction[] + */ + public function getChanges() + { + return $this->changes; + } + + /** + * Forget all changes and remove them from the Session + * + * @return $this + */ + public function clear() + { + $this->hasBeenModified = true; + $this->changes = array(); + $this->session->set($this->getSessionKey(), null); + return $this; + } + + /** + * Whether there are no stacked changes + * + * @return bool + */ + public function isEmpty() + { + return $this->count() === 0; + } + + /** + * Number of stacked changes + * + * @return int + */ + public function count() + { + return count($this->changes); + } + + /** + * Get the first change on the stack, false if empty + * + * @return NodeAction|boolean + */ + public function shift() + { + if ($this->isEmpty()) { + return false; + } + + $this->hasBeenModified = true; + return array_shift($this->changes); + } + + /** + * Get the last change on the stack, false if empty + * + * @return NodeAction|boolean + */ + public function pop() + { + if ($this->isEmpty()) { + return false; + } + + $this->hasBeenModified = true; + return array_pop($this->changes); + } + + /** + * The identifier used for this processes changes in our Session storage + * + * @return string + */ + protected function getSessionKey() + { + return $this->sessionKey; + } + + protected function hasBeenModified() + { + return $this->hasBeenModified; + } + + /** + * @return array + */ + public function serialize() + { + $serialized = array(); + foreach ($this->getChanges() as $change) { + $serialized[] = $change->serialize(); + } + + return $serialized; + } + + /** + * Persist to session on destruction + */ + public function __destruct() + { + if (! $this->hasBeenModified()) { + unset($this->session); + return; + } + $session = $this->session; + $key = $this->getSessionKey(); + if (! $this->isEmpty()) { + $session->set($key, $this->serialize()); + } + unset($this->session); + } +} diff --git a/library/Businessprocess/MonitoredNode.php b/library/Businessprocess/MonitoredNode.php new file mode 100644 index 0000000..7047e5d --- /dev/null +++ b/library/Businessprocess/MonitoredNode.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use ipl\Html\Html; + +abstract class MonitoredNode extends Node +{ + abstract public function getUrl(); + + public function getLink() + { + if ($this->isMissing()) { + return Html::tag('a', ['href' => '#'], $this->getAlias() ?? $this->getName()); + } else { + return Html::tag('a', ['href' => $this->getUrl()], $this->getAlias() ?? $this->getName()); + } + } +} diff --git a/library/Businessprocess/Monitoring/Backend/Ido/Query/CustomVarJoinTemplateOverride.php b/library/Businessprocess/Monitoring/Backend/Ido/Query/CustomVarJoinTemplateOverride.php new file mode 100644 index 0000000..385ca59 --- /dev/null +++ b/library/Businessprocess/Monitoring/Backend/Ido/Query/CustomVarJoinTemplateOverride.php @@ -0,0 +1,84 @@ +<?php + +namespace Icinga\Module\Businessprocess\Monitoring\Backend\Ido\Query; + +use Icinga\Module\Monitoring\Backend\Ido\Query\ServicecommenthistoryQuery; +use Icinga\Module\Monitoring\Backend\Ido\Query\ServicecommentQuery; +use Icinga\Module\Monitoring\Backend\Ido\Query\ServicedowntimeQuery; +use Icinga\Module\Monitoring\Backend\Ido\Query\ServicedowntimestarthistoryQuery; +use Icinga\Module\Monitoring\Backend\Ido\Query\ServiceflappingstarthistoryQuery; +use Icinga\Module\Monitoring\Backend\Ido\Query\ServicegroupQuery; +use Icinga\Module\Monitoring\Backend\Ido\Query\ServicenotificationQuery; +use Icinga\Module\Monitoring\Backend\Ido\Query\ServicestatehistoryQuery; +use Zend_Db_Select; + +trait CustomVarJoinTemplateOverride +{ + private $customVarsJoinTemplate = '%1$s = %2$s.object_id AND %2$s.varname LIKE %3$s'; + + /** + * This is a 1:1 copy of {@see IdoQuery::joinCustomvar()} to be able to + * adjust {@see IdoQuery::$customVarsJoinTemplate} as it's private + */ + protected function joinCustomvar($customvar) + { + // TODO: This is not generic enough yet + list($type, $name) = $this->customvarNameToTypeName($customvar); + $alias = ($type === 'host' ? 'hcv_' : 'scv_') . preg_replace('~[^a-zA-Z0-9_]~', '_', $name); + + // We're replacing any problematic char with an underscore, which will lead to duplicates, this avoids them + $from = $this->select->getPart(Zend_Db_Select::FROM); + for ($i = 2; array_key_exists($alias, $from); $i++) { + $alias = $alias . '_' . $i; + } + + $this->customVars[strtolower($customvar)] = $alias; + + if ($type === 'host') { + if ($this instanceof ServicecommentQuery + || $this instanceof ServicedowntimeQuery + || $this instanceof ServicecommenthistoryQuery + || $this instanceof ServicedowntimestarthistoryQuery + || $this instanceof ServiceflappingstarthistoryQuery + || $this instanceof ServicegroupQuery + || $this instanceof ServicenotificationQuery + || $this instanceof ServicestatehistoryQuery + || $this instanceof \Icinga\Module\Monitoring\Backend\Ido\Query\ServicestatusQuery + ) { + $this->requireVirtualTable('services'); + $leftcol = 's.host_object_id'; + } else { + $leftcol = 'ho.object_id'; + if (! $this->hasJoinedTable('ho')) { + $this->requireVirtualTable('hosts'); + } + } + } else { // $type === 'service' + $leftcol = 'so.object_id'; + if (! $this->hasJoinedTable('so')) { + $this->requireVirtualTable('services'); + } + } + + $mapped = $this->getMappedField($leftcol); + if ($mapped !== null) { + $this->requireColumn($leftcol); + $leftcol = $mapped; + } + + $joinOn = sprintf( + $this->customVarsJoinTemplate, + $leftcol, + $alias, + $this->db->quote($name) + ); + + $this->select->joinLeft( + array($alias => $this->prefix . 'customvariablestatus'), + $joinOn, + array() + ); + + return $this; + } +} diff --git a/library/Businessprocess/Monitoring/Backend/Ido/Query/HostStatusQuery.php b/library/Businessprocess/Monitoring/Backend/Ido/Query/HostStatusQuery.php new file mode 100644 index 0000000..e6ea238 --- /dev/null +++ b/library/Businessprocess/Monitoring/Backend/Ido/Query/HostStatusQuery.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Businessprocess\Monitoring\Backend\Ido\Query; + +class HostStatusQuery extends \Icinga\Module\Monitoring\Backend\Ido\Query\HoststatusQuery +{ + use CustomVarJoinTemplateOverride; +} diff --git a/library/Businessprocess/Monitoring/Backend/Ido/Query/ServiceStatusQuery.php b/library/Businessprocess/Monitoring/Backend/Ido/Query/ServiceStatusQuery.php new file mode 100644 index 0000000..618f3a1 --- /dev/null +++ b/library/Businessprocess/Monitoring/Backend/Ido/Query/ServiceStatusQuery.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Businessprocess\Monitoring\Backend\Ido\Query; + +class ServiceStatusQuery extends \Icinga\Module\Monitoring\Backend\Ido\Query\ServicestatusQuery +{ + use CustomVarJoinTemplateOverride; +} diff --git a/library/Businessprocess/Monitoring/DataView/HostStatus.php b/library/Businessprocess/Monitoring/DataView/HostStatus.php new file mode 100644 index 0000000..edc1814 --- /dev/null +++ b/library/Businessprocess/Monitoring/DataView/HostStatus.php @@ -0,0 +1,16 @@ +<?php + +namespace Icinga\Module\Businessprocess\Monitoring\DataView; + +use Icinga\Data\ConnectionInterface; +use Icinga\Module\Businessprocess\Monitoring\Backend\Ido\Query\HostStatusQuery; + +class HostStatus extends \Icinga\Module\Monitoring\DataView\Hoststatus +{ + public function __construct(ConnectionInterface $connection, array $columns = null) + { + parent::__construct($connection, $columns); + + $this->query = new HostStatusQuery($connection->getResource(), $columns); + } +} diff --git a/library/Businessprocess/Monitoring/DataView/ServiceStatus.php b/library/Businessprocess/Monitoring/DataView/ServiceStatus.php new file mode 100644 index 0000000..f3a9c3c --- /dev/null +++ b/library/Businessprocess/Monitoring/DataView/ServiceStatus.php @@ -0,0 +1,16 @@ +<?php + +namespace Icinga\Module\Businessprocess\Monitoring\DataView; + +use Icinga\Data\ConnectionInterface; +use Icinga\Module\Businessprocess\Monitoring\Backend\Ido\Query\ServiceStatusQuery; + +class ServiceStatus extends \Icinga\Module\Monitoring\DataView\Servicestatus +{ + public function __construct(ConnectionInterface $connection, array $columns = null) + { + parent::__construct($connection, $columns); + + $this->query = new ServiceStatusQuery($connection->getResource(), $columns); + } +} diff --git a/library/Businessprocess/MonitoringRestrictions.php b/library/Businessprocess/MonitoringRestrictions.php new file mode 100644 index 0000000..c7d2cef --- /dev/null +++ b/library/Businessprocess/MonitoringRestrictions.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Authentication\Auth; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\QueryException; + +class MonitoringRestrictions +{ + /** + * Return a filter for the given restriction + * + * @param string $name Name of the restriction + * + * @return Filter|null Filter object or null if the authenticated user is not restricted + * @throws ConfigurationError If the restriction contains invalid filter columns + */ + public static function getRestriction($name) + { + // Borrowed from Icinga\Module\Monitoring\Controller + $restriction = Filter::matchAny(); + $restriction->setAllowedFilterColumns(array( + 'host_name', + 'hostgroup_name', + 'instance_name', + 'service_description', + 'servicegroup_name', + function ($c) { + return preg_match('/^_(?:host|service)_/i', $c); + } + )); + + foreach (Auth::getInstance()->getRestrictions($name) as $filter) { + if ($filter === '*') { + return Filter::matchAny(); + } + + try { + $restriction->addFilter(Filter::fromQueryString($filter)); + } catch (QueryException $e) { + throw new ConfigurationError( + mt( + 'monitoring', + 'Cannot apply restriction %s using the filter %s. You can only use the following columns: %s' + ), + $name, + $filter, + implode(', ', array( + 'instance_name', + 'host_name', + 'hostgroup_name', + 'service_description', + 'servicegroup_name', + '_(host|service)_<customvar-name>' + )), + $e + ); + } + } + + return $restriction; + } +} diff --git a/library/Businessprocess/Node.php b/library/Businessprocess/Node.php new file mode 100644 index 0000000..a0c07d2 --- /dev/null +++ b/library/Businessprocess/Node.php @@ -0,0 +1,570 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Exception\ProgrammingError; +use ipl\Html\Html; +use ipl\Web\Widget\Icon; + +abstract class Node +{ + const FLAG_DOWNTIME = 1; + const FLAG_ACK = 2; + const FLAG_MISSING = 4; + const FLAG_NONE = 8; + const SHIFT_FLAGS = 4; + + const ICINGA_OK = 0; + const ICINGA_WARNING = 1; + const ICINGA_CRITICAL = 2; + const ICINGA_UNKNOWN = 3; + const ICINGA_UP = 0; + const ICINGA_DOWN = 1; + const ICINGA_UNREACHABLE = 2; + const ICINGA_PENDING = 99; + const NODE_EMPTY = 128; + + /** @var bool Whether to treat acknowledged hosts/services always as UP/OK */ + protected static $ackIsOk = false; + + /** @var bool Whether to treat hosts/services in downtime always as UP/OK */ + protected static $downtimeIsOk = false; + + protected $sortStateToStateMap = array( + 4 => self::ICINGA_CRITICAL, + 3 => self::ICINGA_UNKNOWN, + 2 => self::ICINGA_WARNING, + 1 => self::ICINGA_PENDING, + 0 => self::ICINGA_OK + ); + + protected $stateToSortStateMap = array( + self::ICINGA_PENDING => 1, + self::ICINGA_UNKNOWN => 3, + self::ICINGA_CRITICAL => 4, + self::ICINGA_WARNING => 2, + self::ICINGA_OK => 0, + self::NODE_EMPTY => 0 + ); + + /** @var ?string Alias of the node */ + protected $alias; + + /** + * Main business process object + * + * @var BpConfig + */ + protected $bp; + + /** + * Parent nodes + * + * @var array + */ + protected $parents = array(); + + /** + * Node identifier + * + * @var string + */ + protected $name; + + /** + * Node state + * + * @var ?int + */ + protected $state; + + /** + * Whether this nodes state has been acknowledged + * + * @var bool + */ + protected $ack; + + /** + * Whether this node is in a scheduled downtime + * + * @var bool + */ + protected $downtime; + + // obsolete + protected $duration; + + /** + * This node's icon + * + * @var ?string + */ + protected $icon; + + /** + * Last state change, unix timestamp + * + * @var int + */ + protected $lastStateChange; + + protected $missing = false; + + protected $empty = false; + + protected $className = 'unknown'; + + protected $stateNames = array( + 'OK', + 'WARNING', + 'CRITICAL', + 'UNKNOWN', + 99 => 'PENDING', + 128 => 'EMPTY' + ); + + /** + * Set whether to treat acknowledged hosts/services always as UP/OK + * + * @param bool $ackIsOk + */ + public static function setAckIsOk($ackIsOk = true) + { + self::$ackIsOk = $ackIsOk; + } + + /** + * Set whether to treat hosts/services in downtime always as UP/OK + * + * @param bool $downtimeIsOk + */ + public static function setDowntimeIsOk($downtimeIsOk = true) + { + self::$downtimeIsOk = $downtimeIsOk; + } + + public function setBpConfig(BpConfig $bp) + { + $this->bp = $bp; + return $this; + } + + public function getBpConfig() + { + return $this->bp; + } + + public function setMissing($missing = true) + { + $this->missing = $missing; + return $this; + } + + public function isProblem() + { + return $this->getState() > 0; + } + + public function hasBeenChanged() + { + return false; + } + + public function isMissing() + { + return $this->missing; + } + + public function hasMissingChildren() + { + return count($this->getMissingChildren()) > 0; + } + + public function getMissingChildren() + { + return array(); + } + + public function hasInfoUrl() + { + return false; + } + + public function setState($state) + { + $this->state = (int) $state; + $this->missing = false; + return $this; + } + + /** + * Forget my state + * + * @return $this + */ + public function clearState() + { + $this->state = null; + return $this; + } + + public function setAck($ack = true) + { + $this->ack = $ack; + return $this; + } + + public function setDowntime($downtime = true) + { + $this->downtime = $downtime; + return $this; + } + + public function getStateName($state = null) + { + $states = $this->enumStateNames(); + if ($state === null) { + return $states[ $this->getState() ]; + } else { + return $states[ $state ]; + } + } + + public function enumStateNames() + { + return $this->stateNames; + } + + public function getState() + { + if ($this->state === null) { + throw new ProgrammingError( + sprintf( + 'Node %s is unable to retrieve it\'s state', + $this->name + ) + ); + } + + return $this->state; + } + + public function getSortingState($state = null) + { + if ($state === null) { + $state = $this->getState(); + } + + if (self::$ackIsOk && $this->isAcknowledged()) { + $state = self::ICINGA_OK; + } + + if (self::$downtimeIsOk && $this->isInDowntime()) { + $state = self::ICINGA_OK; + } + + $sort = $this->stateToSortState($state); + $sort = ($sort << self::SHIFT_FLAGS) + + ($this->isInDowntime() ? self::FLAG_DOWNTIME : 0) + + ($this->isAcknowledged() ? self::FLAG_ACK : 0); + if (! ($sort & (self::FLAG_DOWNTIME | self::FLAG_ACK))) { + $sort |= self::FLAG_NONE; + } + + return $sort; + } + + public function getLastStateChange() + { + return $this->lastStateChange; + } + + public function setLastStateChange($timestamp) + { + $this->lastStateChange = $timestamp; + return $this; + } + + public function addParent(Node $parent) + { + $this->parents[] = $parent; + return $this; + } + + public function getDuration() + { + return $this->duration; + } + + public function isHandled() + { + return $this->isInDowntime() || $this->isAcknowledged(); + } + + public function isInDowntime() + { + if ($this->downtime === null) { + $this->getState(); + } + return $this->downtime; + } + + public function isAcknowledged() + { + if ($this->ack === null) { + $this->getState(); + } + return $this->ack; + } + + public function getChildren($filter = null) + { + return array(); + } + + public function countChildren($filter = null) + { + return count($this->getChildren($filter)); + } + + public function hasChildren($filter = null) + { + return $this->countChildren($filter) > 0; + } + + public function isEmpty() + { + return $this->countChildren() === 0; + } + + public function hasAlias() + { + return $this->alias !== null; + } + + /** + * Get the alias of the node + * + * @return ?string + */ + public function getAlias() + { + return $this->alias; + } + + /** + * Set the alias of the node + * + * @param string $alias + * + * @return $this + */ + public function setAlias($alias) + { + $this->alias = $alias; + + return $this; + } + + public function hasParents() + { + return count($this->parents) > 0; + } + + public function hasParentName($name) + { + foreach ($this->getParents() as $parent) { + if ($parent->getName() === $name) { + return true; + } + } + + return false; + } + + public function removeParent($name) + { + $this->parents = array_filter( + $this->parents, + function (BpNode $parent) use ($name) { + return $parent->getName() !== $name; + } + ); + + return $this; + } + + /** + * @return BpNode[] + */ + public function getParents() + { + return $this->parents; + } + + /** + * @param BpConfig $rootConfig + * + * @return array + */ + public function getPaths($rootConfig = null) + { + $differentConfig = false; + if ($rootConfig === null) { + $rootConfig = $this->getBpConfig(); + } else { + $differentConfig = $this->getBpConfig()->getName() !== $rootConfig->getName(); + } + + $paths = []; + foreach ($this->parents as $parent) { + foreach ($parent->getPaths($rootConfig) as $path) { + $path[] = $differentConfig ? $this->getIdentifier() : $this->getName(); + $paths[] = $path; + } + } + + if (! $this instanceof ImportedNode && $this->getBpConfig()->hasRootNode($this->getName())) { + $paths[] = [$differentConfig ? $this->getIdentifier() : $this->getName()]; + } elseif (! $this->hasParents()) { + $paths[] = ['__unbound__', $differentConfig ? $this->getIdentifier() : $this->getName()]; + } + + return $paths; + } + + protected function stateToSortState($state) + { + if (array_key_exists($state, $this->stateToSortStateMap)) { + return $this->stateToSortStateMap[$state]; + } + + throw new ProgrammingError( + 'Got invalid state for node %s: %s', + $this->getName(), + var_export($state, true) . var_export($this->stateToSortStateMap, true) + ); + } + + protected function sortStateTostate($sortState) + { + $sortState = $sortState >> self::SHIFT_FLAGS; + if (array_key_exists($sortState, $this->sortStateToStateMap)) { + return $this->sortStateToStateMap[$sortState]; + } + + throw new ProgrammingError('Got invalid sorting state %s', $sortState); + } + + public function getObjectClassName() + { + return $this->className; + } + + public function getLink() + { + return Html::tag('a', ['href' => '#', 'class' => 'toggle'], new Icon('caret-down')); + } + + public function getIcon(): Icon + { + return new Icon($this->icon ?? 'circle-exclamation'); + } + + public function operatorHtml() + { + return ' '; + } + + public function getName() + { + return $this->name; + } + + /** + * Get the Node operators + * + * @return array + */ + public static function getOperators(): array + { + return [ + '&' => t('AND'), + '|' => t('OR'), + '^' => t('XOR'), + '!' => t('NOT'), + '%' => t('DEGRADED'), + '1' => t('MIN 1'), + '2' => t('MIN 2'), + '3' => t('MIN 3'), + '4' => t('MIN 4'), + '5' => t('MIN 5'), + '6' => t('MIN 6'), + '7' => t('MIN 7'), + '8' => t('MIN 8'), + '9' => t('MIN 9'), + ]; + } + + public function getIdentifier() + { + return '@' . $this->getBpConfig()->getName() . ':' . $this->getName(); + } + + public function __toString() + { + return $this->getName(); + } + + public function __destruct() + { + unset($this->parents); + } + + /** + * Export the node to array + * + * @param array $parent The node's parent. Used to construct the path to the node + * @param bool $flat If false, children will be added to the array key children, else the array will be flat + * + * @return array + */ + public function toArray(array $parent = null, $flat = false) + { + $data = [ + 'name' => $this->getAlias(), + 'state' => $this->getStateName(), + 'since' => $this->getLastStateChange(), + 'in_downtime' => $this->isInDowntime() ? true : false + ]; + + if ($parent !== null) { + $data['path'] = $parent['path'] . '!' . $this->getAlias(); + } else { + $data['path'] = $this->getAlias(); + } + + $children = []; + + foreach ($this->getChildren() as $node) { + if ($flat) { + $children = array_merge($children, $node->toArray($data, $flat)); + } else { + $children[] = $node->toArray($data, $flat); + } + } + + if ($flat) { + $data = [$data]; + + if (! empty($children)) { + $data = array_merge($data, $children); + } + } else { + $data['children'] = $children; + } + + return $data; + } +} diff --git a/library/Businessprocess/ProvidedHook/Icingadb/HostActions.php b/library/Businessprocess/ProvidedHook/Icingadb/HostActions.php new file mode 100644 index 0000000..ac18959 --- /dev/null +++ b/library/Businessprocess/ProvidedHook/Icingadb/HostActions.php @@ -0,0 +1,23 @@ +<?php + +namespace Icinga\Module\Businessprocess\ProvidedHook\Icingadb; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Icingadb\Hook\HostActionsHook; +use Icinga\Module\Icingadb\Model\Host; +use ipl\Web\Widget\Link; + +class HostActions extends HostActionsHook +{ + public function getActionsForObject(Host $host): array + { + $label = mt('businessprocess', 'Business Impact'); + return array( + new Link( + $label, + 'businessprocess/node/impact?name=' + . rawurlencode(BpConfig::joinNodeName($host->name, 'Hoststatus')) + ) + ); + } +} diff --git a/library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php b/library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php new file mode 100644 index 0000000..1ff37d3 --- /dev/null +++ b/library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php @@ -0,0 +1,10 @@ +<?php + +namespace Icinga\Module\Businessprocess\ProvidedHook\Icingadb; + +use Icinga\Module\Icingadb\Hook\IcingadbSupportHook; + +class IcingadbSupport extends IcingadbSupportHook +{ + +} diff --git a/library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php b/library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php new file mode 100644 index 0000000..d416d90 --- /dev/null +++ b/library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Businessprocess\ProvidedHook\Icingadb; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Icingadb\Hook\ServiceActionsHook; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Web\Widget\Link; + +class ServiceActions extends ServiceActionsHook +{ + public function getActionsForObject(Service $service): array + { + $label = mt('businessprocess', 'Business Impact'); + return array( + new Link( + $label, + sprintf( + 'businessprocess/node/impact?name=%s', + rawurlencode(BpConfig::joinNodeName($service->host->name, $service->name)) + ) + ) + ); + } +} diff --git a/library/Businessprocess/ProvidedHook/Icingadb/ServiceDetailExtension.php b/library/Businessprocess/ProvidedHook/Icingadb/ServiceDetailExtension.php new file mode 100644 index 0000000..6d10af2 --- /dev/null +++ b/library/Businessprocess/ProvidedHook/Icingadb/ServiceDetailExtension.php @@ -0,0 +1,77 @@ +<?php + +namespace Icinga\Module\Businessprocess\ProvidedHook\Icingadb; + +use Icinga\Module\Businessprocess\Renderer\TileRenderer; +use Icinga\Module\Businessprocess\Renderer\TreeRenderer; +use Icinga\Module\Businessprocess\State\IcingaDbState; +use Icinga\Module\Businessprocess\Storage\LegacyStorage; +use Icinga\Module\Businessprocess\Web\Url; +use Icinga\Module\Icingadb\Hook\ServiceDetailExtensionHook; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use ipl\Html\ValidHtml; + +class ServiceDetailExtension extends ServiceDetailExtensionHook +{ + /** @var ?LegacyStorage */ + private $storage; + + /** @var string */ + private $commandName; + + protected function init() + { + $this->setSection(self::GRAPH_SECTION); + + try { + $this->storage = LegacyStorage::getInstance(); + $this->commandName = $this->getModule()->getConfig()->get( + 'DetailviewExtension', + 'checkcommand_name', + 'icingacli-businessprocess' + ); + } catch (\Exception $e) { + // Ignore and don't display anything + } + } + + public function getHtmlForObject(Service $service): ValidHtml + { + if (! isset($this->storage) + || $service->checkcommand_name !== $this->commandName + ) { + return HtmlString::create(''); + } + + $bpName = $service->customvars['icingacli_businessprocess_config'] ?? null; + if (! $bpName) { + $bpName = key($this->storage->listProcessNames()); + } + + $nodeName = $service->customvars['icingacli_businessprocess_process'] ?? null; + if (! $nodeName) { + return HtmlString::create(''); + } + + $bp = $this->storage->loadProcess($bpName); + $node = $bp->getBpNode($nodeName); + + IcingaDbState::apply($bp); + + if ($service->customvars['icingaweb_businessprocess_as_tree'] ?? false) { + $renderer = new TreeRenderer($bp, $node); + $tag = 'ul'; + } else { + $renderer = new TileRenderer($bp, $node); + $tag = 'div'; + } + + $renderer->setUrl(Url::fromPath('businessprocess/process/show?config=' . $bpName . '&node=' . $nodeName)); + $renderer->ensureAssembled()->getFirst($tag)->setAttribute('data-base-target', '_next'); + + return (new HtmlDocument())->addHtml(Html::tag('h2', 'Business Process'), $renderer); + } +} diff --git a/library/Businessprocess/ProvidedHook/Monitoring/DetailviewExtension.php b/library/Businessprocess/ProvidedHook/Monitoring/DetailviewExtension.php new file mode 100644 index 0000000..691acec --- /dev/null +++ b/library/Businessprocess/ProvidedHook/Monitoring/DetailviewExtension.php @@ -0,0 +1,83 @@ +<?php + +namespace Icinga\Module\Businessprocess\ProvidedHook\Monitoring; + +use Icinga\Module\Businessprocess\Renderer\TileRenderer; +use Icinga\Module\Businessprocess\Renderer\TreeRenderer; +use Icinga\Module\Businessprocess\State\MonitoringState; +use Icinga\Module\Businessprocess\Storage\LegacyStorage; +use Icinga\Module\Businessprocess\Web\Url; +use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook; +use Icinga\Module\Monitoring\Object\MonitoredObject; +use Icinga\Module\Monitoring\Object\Service; + +class DetailviewExtension extends DetailviewExtensionHook +{ + /** @var ?LegacyStorage */ + private $storage; + + /** @var string */ + private $commandName; + + /** + * Initialize storage + */ + public function init() + { + try { + $this->storage = LegacyStorage::getInstance(); + $this->commandName = $this->getModule()->getConfig()->get( + 'DetailviewExtension', + 'checkcommand_name', + 'icingacli-businessprocess' + ); + } catch (\Exception $e) { + // Ignore and don't display anything + } + } + + /** + * Returns the rendered Tree-/TileRenderer HTML + * + * @param MonitoredObject $object + * + * @return string + */ + public function getHtmlForObject(MonitoredObject $object) + { + if (! isset($this->storage) + || ! $object instanceof Service + || $object->check_command !== $this->commandName + ) { + return ''; + } + + $bpName = $object->_service_icingacli_businessprocess_config; + if (! $bpName) { + $bpName = key($this->storage->listProcessNames()); + } + + $nodeName = $object->_service_icingacli_businessprocess_process; + if (! $nodeName) { + return ''; + } + + $bp = $this->storage->loadProcess($bpName); + $node = $bp->getBpNode($nodeName); + + MonitoringState::apply($bp); + + if (filter_var($object->_service_icingaweb_businessprocess_as_tree, FILTER_VALIDATE_BOOLEAN)) { + $renderer = new TreeRenderer($bp, $node); + $tag = 'ul'; + } else { + $renderer = new TileRenderer($bp, $node); + $tag = 'div'; + } + + $renderer->setUrl(Url::fromPath('businessprocess/process/show?config=' . $bpName . '&node=' . $nodeName)); + $renderer->ensureAssembled()->getFirst($tag)->setAttribute('data-base-target', '_next'); + + return '<h2>Business Process</h2>' . $renderer; + } +} diff --git a/library/Businessprocess/ProvidedHook/Monitoring/HostActions.php b/library/Businessprocess/ProvidedHook/Monitoring/HostActions.php new file mode 100644 index 0000000..e2b9c59 --- /dev/null +++ b/library/Businessprocess/ProvidedHook/Monitoring/HostActions.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Businessprocess\ProvidedHook\Monitoring; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Monitoring\Hook\HostActionsHook; +use Icinga\Module\Monitoring\Object\Host; + +class HostActions extends HostActionsHook +{ + public function getActionsForHost(Host $host) + { + $label = mt('businessprocess', 'Business Impact'); + return array( + $label => 'businessprocess/node/impact?name=' + . rawurlencode(BpConfig::joinNodeName($host->getName(), 'Hoststatus')) + ); + } +} diff --git a/library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php b/library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php new file mode 100644 index 0000000..ce9fabf --- /dev/null +++ b/library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php @@ -0,0 +1,24 @@ +<?php + +namespace Icinga\Module\Businessprocess\ProvidedHook\Monitoring; + +use Exception; +use Icinga\Application\Config; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Monitoring\Hook\ServiceActionsHook; +use Icinga\Module\Monitoring\Object\Service; +use Icinga\Web\Url; + +class ServiceActions extends ServiceActionsHook +{ + public function getActionsForService(Service $service) + { + $label = mt('businessprocess', 'Business Impact'); + return array( + $label => sprintf( + 'businessprocess/node/impact?name=%s', + rawurlencode(BpConfig::joinNodeName($service->getHost()->getName(), $service->getName())) + ) + ); + } +} diff --git a/library/Businessprocess/Renderer/Breadcrumb.php b/library/Businessprocess/Renderer/Breadcrumb.php new file mode 100644 index 0000000..4272b76 --- /dev/null +++ b/library/Businessprocess/Renderer/Breadcrumb.php @@ -0,0 +1,80 @@ +<?php + +namespace Icinga\Module\Businessprocess\Renderer; + +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\Renderer\TileRenderer\NodeTile; +use Icinga\Module\Businessprocess\Web\Url; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Widget\Icon; + +class Breadcrumb extends BaseHtmlElement +{ + protected $tag = 'ul'; + + protected $defaultAttributes = array( + 'class' => 'breadcrumb', + 'data-base-target' => '_main' + ); + + /** + * @param Renderer $renderer + * @return static + */ + public static function create(Renderer $renderer) + { + $bp = $renderer->getBusinessProcess(); + $breadcrumb = new static; + $bpUrl = $renderer->getBaseUrl(); + if ($bpUrl->getParam('action') === 'delete') { + $bpUrl->remove('action'); + } + + $breadcrumb->add(Html::tag('li')->add( + Html::tag( + 'a', + [ + 'href' => Url::fromPath('businessprocess'), + 'title' => mt('businessprocess', 'Show Overview') + ], + new Icon('house') + ) + )); + $breadcrumb->add(Html::tag('li')->add( + Html::tag('a', ['href' => $bpUrl], $bp->getTitle()) + )); + $path = $renderer->getCurrentPath(); + + $parts = array(); + while ($nodeName = array_pop($path)) { + /** @var BpNode $node */ + $node = $bp->getNode($nodeName); + $renderer->setParentNode($node); + array_unshift( + $parts, + static::renderNode($node, $path, $renderer) + ); + } + $breadcrumb->add($parts); + + return $breadcrumb; + } + + /** + * @param BpNode $node + * @param array $path + * @param Renderer $renderer + * + * @return NodeTile + */ + protected static function renderNode(BpNode $node, $path, Renderer $renderer) + { + // TODO: something more generic than NodeTile? + $renderer = clone($renderer); + $renderer->lock()->setIsBreadcrumb(); + $p = new NodeTile($renderer, $node, $path); + $p->setTag('li'); + return $p; + } +} diff --git a/library/Businessprocess/Renderer/Renderer.php b/library/Businessprocess/Renderer/Renderer.php new file mode 100644 index 0000000..6a5d624 --- /dev/null +++ b/library/Businessprocess/Renderer/Renderer.php @@ -0,0 +1,431 @@ +<?php + +namespace Icinga\Module\Businessprocess\Renderer; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Common\Sort; +use Icinga\Module\Businessprocess\ImportedNode; +use Icinga\Module\Businessprocess\MonitoredNode; +use Icinga\Module\Businessprocess\Node; +use Icinga\Module\Businessprocess\Web\Url; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Stdlib\Str; +use ipl\Web\Widget\StateBadge; + +abstract class Renderer extends HtmlDocument +{ + use Sort; + + /** @var BpConfig */ + protected $config; + + /** @var BpNode */ + protected $parent; + + /** @var bool Administrative actions are hidden unless unlocked */ + protected $locked = true; + + /** @var Url */ + protected $url; + + /** @var Url */ + protected $baseUrl; + + /** @var array */ + protected $path = array(); + + /** @var bool */ + protected $isBreadcrumb = false; + + /** + * Renderer constructor. + * + * @param BpConfig $config + * @param BpNode|null $parent + */ + public function __construct(BpConfig $config, BpNode $parent = null) + { + $this->config = $config; + $this->parent = $parent; + } + + /** + * @return BpConfig + */ + public function getBusinessProcess() + { + return $this->config; + } + + /** + * Whether this will render all root nodes + * + * @return bool + */ + public function wantsRootNodes() + { + return $this->parent === null; + } + + /** + * Whether this will only render parts of given config + * + * @return bool + */ + public function rendersSubNode() + { + return $this->parent !== null; + } + + public function rendersImportedNode() + { + return $this->parent !== null && $this->parent->getBpConfig()->getName() !== $this->config->getName(); + } + + public function setParentNode(BpNode $node) + { + $this->parent = $node; + return $this; + } + + /** + * @return BpNode + */ + public function getParentNode() + { + return $this->parent; + } + + /** + * @return BpNode[] + */ + public function getParentNodes() + { + if ($this->wantsRootNodes()) { + return array(); + } + + return $this->parent->getParents(); + } + + /** + * @return BpNode[] + */ + public function getChildNodes() + { + if ($this->wantsRootNodes()) { + return $this->config->getRootNodes(); + } else { + return $this->parent->getChildren(); + } + } + + /** + * Get the default sort specification + * + * @return string + */ + public function getDefaultSort(): string + { + if ($this->config->getMetadata()->isManuallyOrdered()) { + return 'manual asc'; + } + + return 'display_name asc'; + } + + /** + * Get whether a custom sort order is applied + * + * @return bool + */ + public function appliesCustomSorting(): bool + { + if (empty($this->getSort())) { + return false; + } + + list($sortBy, $_) = Str::symmetricSplit($this->getSort(), ' ', 2); + list($defaultSortBy, $_) = Str::symmetricSplit($this->getDefaultSort(), ' ', 2); + + return $sortBy !== $defaultSortBy; + } + + /** + * @return int + */ + public function countChildNodes() + { + if ($this->wantsRootNodes()) { + return $this->config->countChildren(); + } else { + return $this->parent->countChildren(); + } + } + + /** + * @param $summary + * @return ?BaseHtmlElement + */ + public function renderStateBadges($summary, $totalChildren) + { + $itemCount = Html::tag( + 'span', + [ + 'class' => [ + 'item-count', + ] + ], + sprintf(mtp('businessprocess', '%u Child', '%u Children', $totalChildren), $totalChildren) + ); + + $elements = array_filter([ + $this->createBadgeGroup($summary, 'CRITICAL'), + $this->createBadgeGroup($summary, 'UNKNOWN'), + $this->createBadgeGroup($summary, 'WARNING'), + $this->createBadge($summary, 'MISSING'), + $this->createBadge($summary, 'PENDING') + ]); + + if (!empty($elements)) { + $container = Html::tag('ul', ['class' => 'state-badges']); + $container->add($itemCount); + foreach ($elements as $element) { + $container->add($element); + } + + return $container; + } + return null; + } + + protected function createBadge($summary, $state) + { + if ($summary[$state] !== 0) { + return Html::tag('li', new StateBadge($summary[$state], strtolower($state))); + } + + return null; + } + + protected function createBadgeGroup($summary, $state) + { + $content = []; + if ($summary[$state] !== 0) { + $content[] = Html::tag('li', new StateBadge($summary[$state], strtolower($state))); + } + + if ($summary[$state . '-HANDLED'] !== 0) { + $content[] = Html::tag('li', new StateBadge($summary[$state . '-HANDLED'], strtolower($state), true)); + } + + if (empty($content)) { + return null; + } + + return Html::tag('li', Html::tag('ul', $content)); + } + + public function getNodeClasses(Node $node) + { + if ($node->isMissing()) { + $classes = array('missing'); + } else { + if ($node->isEmpty() && ! $node instanceof MonitoredNode) { + $classes = array('empty'); + } else { + $classes = [strtolower($node->getStateName( + $this->parent !== null ? $this->parent->getChildState($node) : null + ))]; + } + if ($node->hasMissingChildren()) { + $classes[] = 'missing-children'; + } + } + + if ($node->isHandled()) { + $classes[] = 'handled'; + } + + if ($node instanceof BpNode) { + $classes[] = 'process-node'; + } else { + $classes[] = 'monitored-node'; + } + // TODO: problem? + return $classes; + } + + /** + * Return the url to the given node's source configuration + * + * @param BpNode $node + * + * @return Url + */ + public function getSourceUrl(BpNode $node) + { + if ($node instanceof ImportedNode) { + $name = $node->getNodeName(); + $paths = $node->getBpConfig()->getBpNode($name)->getPaths(); + } else { + $name = $node->getName(); + $paths = $node->getPaths(); + } + + $url = clone $this->getUrl(); + $url->setParams([ + 'config' => $node->getBpConfig()->getName(), + 'node' => $name + ]); + // This depends on the fact that the node's root path is the last element in $paths + $url->getParams()->addValues('path', array_slice(array_pop($paths), 0, -1)); + if (! $this->isLocked()) { + $url->getParams()->add('unlocked', true); + } + + return $url; + } + + /** + * @param Node $node + * @param $path + * @return string + */ + public function getId(Node $node, $path) + { + return 'businessprocess-' . md5((empty($path) ? '' : implode(';', $path)) . $node->getName()); + } + + public function setPath(array $path) + { + $this->path = $path; + return $this; + } + + /** + * @return array + */ + public function getPath() + { + return $this->path; + } + + public function getCurrentPath() + { + $path = $this->getPath(); + if ($this->rendersSubNode()) { + $path[] = $this->rendersImportedNode() + ? $this->parent->getIdentifier() + : $this->parent->getName(); + } + + return $path; + } + + /** + * @param Url $url + * @return $this + */ + public function setUrl(Url $url) + { + $this->url = $url->without(array( + 'action', + 'deletenode', + 'deleteparent', + 'editnode', + 'simulationnode', + 'view' + )); + $this->setBaseUrl($this->url); + return $this; + } + + /** + * @param Url $url + * @return $this + */ + protected function setBaseUrl(Url $url) + { + $this->baseUrl = $url->without(array('node', 'path')); + return $this; + } + + public function getUrl() + { + return $this->url; + } + + /** + * @return Url + * @throws ProgrammingError + */ + public function getBaseUrl() + { + if ($this->baseUrl === null) { + throw new ProgrammingError('Renderer has no baseUrl'); + } + + return clone($this->baseUrl); + } + + /** + * @return bool + */ + public function isLocked() + { + return $this->locked; + } + + /** + * @return $this + */ + public function lock() + { + $this->locked = true; + return $this; + } + + /** + * @return $this + */ + public function unlock() + { + $this->locked = false; + return $this; + } + + /** + * TODO: Get rid of this + * + * @return $this + */ + public function setIsBreadcrumb() + { + $this->isBreadcrumb = true; + return $this; + } + + public function isBreadcrumb() + { + return $this->isBreadcrumb; + } + + protected function createUnboundParent(BpConfig $bp) + { + return $bp->getNode('__unbound__'); + } + + /** + * Just to be on the safe side + */ + public function __destruct() + { + unset($this->parent); + unset($this->config); + } +} diff --git a/library/Businessprocess/Renderer/TileRenderer.php b/library/Businessprocess/Renderer/TileRenderer.php new file mode 100644 index 0000000..21c2f6a --- /dev/null +++ b/library/Businessprocess/Renderer/TileRenderer.php @@ -0,0 +1,85 @@ +<?php + +namespace Icinga\Module\Businessprocess\Renderer; + +use Icinga\Module\Businessprocess\ImportedNode; +use Icinga\Module\Businessprocess\Renderer\TileRenderer\NodeTile; +use Icinga\Module\Businessprocess\Web\Form\CsrfToken; +use ipl\Html\Html; + +class TileRenderer extends Renderer +{ + public function assemble() + { + $bp = $this->config; + $nodesDiv = Html::tag( + 'div', + [ + 'class' => ['sortable', 'tiles', $this->howMany()], + 'data-base-target' => '_self', + 'data-sortable-disabled' => $this->isLocked() || $this->appliesCustomSorting() + ? 'true' + : 'false', + 'data-sortable-data-id-attr' => 'id', + 'data-sortable-direction' => 'horizontal', // Otherwise movement is buggy on small lists + 'data-csrf-token' => CsrfToken::generate() + ] + ); + + if ($this->wantsRootNodes()) { + $nodesDiv->getAttributes()->add( + 'data-action-url', + $this->getUrl()->with(['config' => $bp->getName()])->getAbsoluteUrl() + ); + } else { + $nodeName = $this->parent instanceof ImportedNode + ? $this->parent->getNodeName() + : $this->parent->getName(); + $nodesDiv->getAttributes() + ->add('data-node-name', $nodeName) + ->add('data-action-url', $this->getUrl() + ->with([ + 'config' => $this->parent->getBpConfig()->getName(), + 'node' => $nodeName + ]) + ->getAbsoluteUrl()); + } + + $path = $this->getCurrentPath(); + foreach ($this->sort($this->getChildNodes()) as $name => $node) { + $this->add(new NodeTile($this, $node, $path)); + } + + if ($this->wantsRootNodes()) { + $unbound = $this->createUnboundParent($bp); + if ($unbound->hasChildren()) { + $this->add(new NodeTile($this, $unbound)); + } + } + + $nodesDiv->addHtml(...$this->getContent()); + $this->setHtmlContent($nodesDiv); + } + + /** + * A CSS class giving a rough indication of how many nodes we have + * + * This is used to show larger tiles when there are few and smaller + * ones if there are many. + * + * @return string + */ + protected function howMany() + { + $count = $this->countChildNodes(); + $howMany = 'normal'; + + if ($count <= 6) { + $howMany = 'few'; + } elseif ($count > 12) { + $howMany = 'many'; + } + + return $howMany; + } +} diff --git a/library/Businessprocess/Renderer/TileRenderer/NodeTile.php b/library/Businessprocess/Renderer/TileRenderer/NodeTile.php new file mode 100644 index 0000000..1f32f54 --- /dev/null +++ b/library/Businessprocess/Renderer/TileRenderer/NodeTile.php @@ -0,0 +1,353 @@ +<?php + +namespace Icinga\Module\Businessprocess\Renderer\TileRenderer; + +use Icinga\Date\DateFormatter; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\ImportedNode; +use Icinga\Module\Businessprocess\MonitoredNode; +use Icinga\Module\Businessprocess\Node; +use Icinga\Module\Businessprocess\Renderer\Renderer; +use Icinga\Web\Url; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBall; + +class NodeTile extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $renderer; + + protected $name; + + protected $node; + + protected $path; + + /** + * @var BaseHtmlElement + */ + private $actions; + + /** + * NodeTile constructor. + * @param Renderer $renderer + * @param Node $node + * @param ?array $path + */ + public function __construct(Renderer $renderer, Node $node, $path = null) + { + $this->renderer = $renderer; + $this->node = $node; + $this->path = $path; + } + + protected function actions() + { + if ($this->actions === null) { + $this->addActions(); + } + return $this->actions; + } + + protected function addActions() + { + $this->actions = Html::tag( + 'div', + [ + 'class' => 'actions' + ] + ); + + return $this->add($this->actions); + } + + public function render() + { + $renderer = $this->renderer; + $node = $this->node; + + $attributes = $this->getAttributes(); + $attributes->add('class', $renderer->getNodeClasses($node)); + $attributes->add('id', $renderer->getId($node, $this->path)); + if (! $renderer->isLocked()) { + $attributes->add('data-node-name', $node->getName()); + } + + if (! $renderer->isBreadcrumb()) { + $this->addDetailsActions(); + + if (! $renderer->isLocked()) { + $this->addActionLinks(); + } + } + if (! $node instanceof ImportedNode || $node->getBpConfig()->hasNode($node->getName())) { + $link = $this->getMainNodeLink(); + if ($renderer->isBreadcrumb()) { + $state = strtolower($node->getStateName()); + if ($node->isHandled()) { + $state = $state . ' handled'; + } + $link->prepend((new StateBall($state, StateBall::SIZE_MEDIUM))->addAttributes([ + 'title' => sprintf( + '%s %s', + $state, + DateFormatter::timeSince($node->getLastStateChange()) + ) + ])); + } + + $this->add($link); + } else { + $this->add(new Link($node->getAlias(), $this->getMainNodeUrl($node)->getAbsoluteUrl())); + } + + if ($this->renderer->rendersSubNode() + && $this->renderer->getParentNode()->getChildState($node) !== $node->getState() + ) { + $this->add( + (new StateBall(strtolower($node->getStateName()), StateBall::SIZE_MEDIUM)) + ->addAttributes([ + 'class' => 'overridden-state', + 'title' => sprintf( + '%s', + $node->getStateName() + ) + ]) + ); + } + + if ($node instanceof BpNode && !$renderer->isBreadcrumb()) { + $this->add($renderer->renderStateBadges($node->getStateSummary(), $node->countChildren())); + } + + return parent::render(); + } + + protected function getMainNodeUrl(Node $node) + { + if ($node instanceof BpNode) { + return $this->makeBpUrl($node); + } else { + /** @var MonitoredNode $node */ + return $node->getUrl(); + } + } + + protected function buildBaseNodeUrl(Node $node) + { + $url = $this->renderer->getBaseUrl(); + + $p = $url->getParams(); + if ($node instanceof ImportedNode + && $this->renderer->getBusinessProcess()->getName() === $node->getBpConfig()->getName() + ) { + $p->set('node', $node->getNodeName()); + } elseif ($this->renderer->rendersImportedNode()) { + $p->set('node', $node->getIdentifier()); + } else { + $p->set('node', $node->getName()); + } + + if (! empty($this->path)) { + $p->addValues('path', $this->path); + } + + return $url; + } + + protected function makeBpUrl(BpNode $node) + { + return $this->buildBaseNodeUrl($node); + } + + /** + * @return BaseHtmlElement + */ + protected function getMainNodeLink() + { + $node = $this->node; + $url = $this->getMainNodeUrl($node); + if ($node instanceof MonitoredNode) { + $link = Html::tag( + 'a', + ['href' => $url, 'data-base-target' => '_next'], + $node->getAlias() ?? $node->getName() + ); + } else { + $link = Html::tag('a', ['href' => $url], $node->getAlias()); + } + + return $link; + } + + protected function addDetailsActions() + { + $node = $this->node; + $url = $this->getMainNodeUrl($node); + + if ($node instanceof BpNode) { + $this->actions()->add(Html::tag( + 'a', + [ + 'href' => $url->with('mode', 'tile'), + 'title' => mt('businessprocess', 'Show tiles for this subtree') + ], + new Icon('grip') + ))->add(Html::tag( + 'a', + [ + 'href' => $url->with('mode', 'tree'), + 'title' => mt('businessprocess', 'Show this subtree as a tree') + ], + new Icon('sitemap') + )); + if ($node instanceof ImportedNode) { + $bpConfig = $node->getBpConfig(); + if ($bpConfig->isFaulty() || $bpConfig->hasNode($node->getName())) { + $this->actions()->add(Html::tag( + 'a', + [ + 'data-base-target' => '_next', + 'href' => $bpConfig->isFaulty() + ? $this->renderer->getBaseUrl()->setParam('config', $bpConfig->getName()) + : $this->renderer->getSourceUrl($node)->getAbsoluteUrl(), + 'title' => mt( + 'businessprocess', + 'Show this process as part of its original configuration' + ) + ], + new Icon('share') + )); + } + } + + $url = $node->getInfoUrl(); + + if ($url !== null) { + $link = Html::tag( + 'a', + [ + 'href' => $url, + 'class' => 'node-info', + 'title' => sprintf('%s: %s', mt('businessprocess', 'More information'), $url) + ], + new Icon('info') + ); + if (preg_match('#^http(?:s)?://#', $url)) { + $link->addAttributes(['target' => '_blank']); + } + $this->actions()->add($link); + } + } else { + $this->actions()->add(Html::tag( + 'a', + ['href' => $node->getUrl(), 'data-base-target' => '_next'], + $node->getIcon() + )); + } + + if ($node->isAcknowledged()) { + $this->actions()->add(new Icon('check', ['class' => 'handled-icon'])); + } elseif ($node->isInDowntime()) { + $this->actions()->add(new Icon('plug', ['class' => 'handled-icon'])); + } + } + + protected function addActionLinks() + { + $parent = $this->renderer->getParentNode(); + if ($parent !== null) { + $baseUrl = Url::fromPath('businessprocess/process/show', [ + 'config' => $parent->getBpConfig()->getName(), + 'node' => $parent instanceof ImportedNode + ? $parent->getNodeName() + : $parent->getName(), + 'unlocked' => true + ]); + } else { + $baseUrl = Url::fromPath('businessprocess/process/show', [ + 'config' => $this->node->getBpConfig()->getName(), + 'unlocked' => true + ]); + } + + if ($this->node instanceof MonitoredNode) { + $this->actions()->add(Html::tag( + 'a', + [ + 'href' => $baseUrl + ->with('action', 'simulation') + ->with('simulationnode', $this->node->getName()), + 'title' => mt( + 'businessprocess', + 'Show the business impact of this node by simulating a specific state' + ) + ], + new Icon('wand-magic-sparkles') + )); + + $this->actions()->add(Html::tag( + 'a', + [ + 'href' => $baseUrl + ->with('action', 'editmonitored') + ->with('editmonitorednode', $this->node->getName()), + 'title' => mt('businessprocess', 'Modify this monitored node') + ], + new Icon('edit') + )); + } + + if ($this->renderer->getBusinessProcess()->getMetadata()->canModify() + && $this->node->getBpConfig()->getName() === $this->renderer->getBusinessProcess()->getName() + && $this->node->getName() !== '__unbound__' + ) { + if ($this->node instanceof BpNode) { + $this->actions()->add(Html::tag( + 'a', + [ + 'href' => $baseUrl + ->with('action', 'edit') + ->with('editnode', $this->node->getName()), + 'title' => mt('businessprocess', 'Modify this business process node') + ], + new Icon('edit') + )); + + $addUrl = $baseUrl->with([ + 'node' => $this->node->getName(), + 'action' => 'add' + ]); + $addUrl->getParams()->addValues('path', $this->path); + $this->actions()->add(Html::tag( + 'a', + [ + 'href' => $addUrl, + 'title' => mt('businessprocess', 'Add a new sub-node to this business process') + ], + new Icon('plus') + )); + } + } + + if ($this->renderer->getBusinessProcess()->getMetadata()->canModify()) { + $params = array( + 'action' => 'delete', + 'deletenode' => $this->node->getName(), + ); + + $this->actions()->add(Html::tag( + 'a', + [ + 'href' => $baseUrl->with($params), + 'title' => mt('businessprocess', 'Delete this node') + ], + new Icon('xmark') + )); + } + } +} diff --git a/library/Businessprocess/Renderer/TreeRenderer.php b/library/Businessprocess/Renderer/TreeRenderer.php new file mode 100644 index 0000000..097d148 --- /dev/null +++ b/library/Businessprocess/Renderer/TreeRenderer.php @@ -0,0 +1,380 @@ +<?php + +namespace Icinga\Module\Businessprocess\Renderer; + +use Icinga\Application\Version; +use Icinga\Date\DateFormatter; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\ImportedNode; +use Icinga\Module\Businessprocess\Node; +use Icinga\Module\Businessprocess\Web\Form\CsrfToken; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBall; + +class TreeRenderer extends Renderer +{ + const NEW_COLLAPSIBLE_IMPLEMENTATION_SINCE = '2.11.2'; + + public function assemble() + { + $bp = $this->config; + $htmlId = $bp->getHtmlId(); + $tree = Html::tag( + 'ul', + [ + 'id' => $htmlId, + 'class' => ['bp', 'sortable', $this->wantsRootNodes() ? '' : 'process'], + 'data-sortable-disabled' => $this->isLocked() || $this->appliesCustomSorting() + ? 'true' + : 'false', + 'data-sortable-data-id-attr' => 'id', + 'data-sortable-direction' => 'vertical', + 'data-sortable-group' => json_encode([ + 'name' => $this->wantsRootNodes() ? 'root' : $htmlId, + 'put' => 'function:rowPutAllowed' + ]), + 'data-sortable-invert-swap' => 'true', + 'data-csrf-token' => CsrfToken::generate() + ], + $this->renderBp($bp) + ); + if ($this->wantsRootNodes()) { + $tree->getAttributes()->add( + 'data-action-url', + $this->getUrl()->with(['config' => $bp->getName()])->getAbsoluteUrl() + ); + + if (version_compare(Version::VERSION, self::NEW_COLLAPSIBLE_IMPLEMENTATION_SINCE, '<')) { + $tree->getAttributes()->add('data-is-root-config', true); + } + } else { + $nodeName = $this->parent instanceof ImportedNode + ? $this->parent->getNodeName() + : $this->parent->getName(); + $tree->getAttributes() + ->add('data-node-name', $nodeName) + ->add('data-action-url', $this->getUrl() + ->with([ + 'config' => $this->parent->getBpConfig()->getName(), + 'node' => $nodeName + ]) + ->getAbsoluteUrl()); + } + + $this->addHtml($tree); + } + + /** + * @param BpConfig $bp + * @return array + */ + public function renderBp(BpConfig $bp) + { + $html = []; + if ($this->wantsRootNodes()) { + $nodes = $bp->getRootNodes(); + } else { + $nodes = $this->parent->getChildren(); + } + + foreach ($this->sort($nodes) as $name => $node) { + if ($node instanceof BpNode) { + $html[] = $this->renderNode($bp, $node); + } else { + $html[] = $this->renderChild($bp, $this->parent, $node); + } + } + + return $html; + } + + protected function getStateClassNames(Node $node) + { + $state = strtolower($node->getStateName()); + + if ($node->isMissing()) { + return array('missing'); + } elseif ($state === 'ok') { + if ($node->hasMissingChildren()) { + return array('ok', 'missing-children'); + } else { + return array('ok'); + } + } else { + return array('problem', $state); + } + } + + /** + * @param Node $node + * @param array $path + * @param BpNode $parent + * @return BaseHtmlElement[] + */ + public function getNodeIcons(Node $node, array $path = null, BpNode $parent = null) + { + $icons = []; + if (empty($path) && $node instanceof BpNode) { + $icons[] = new Icon('sitemap'); + } else { + $icons[] = $node->getIcon(); + } + $state = strtolower($node->getStateName($parent !== null ? $parent->getChildState($node) : null)); + if ($node->isHandled()) { + $state = $state . ' handled'; + } + $icons[] = (new StateBall($state, StateBall::SIZE_MEDIUM))->addAttributes([ + 'title' => sprintf( + '%s %s', + $state, + DateFormatter::timeSince($node->getLastStateChange()) + ) + ]); + + if ($node->isAcknowledged()) { + $icons[] = new Icon('check'); + } elseif ($node->isInDowntime()) { + $icons[] = new Icon('plug'); + } + + return $icons; + } + + public function getOverriddenState($fakeState, Node $node) + { + $overriddenState = Html::tag('div', ['class' => 'overridden-state']); + $overriddenState->add( + (new StateBall(strtolower($node->getStateName()), StateBall::SIZE_MEDIUM)) + ->addAttributes([ + 'title' => sprintf( + '%s', + $node->getStateName() + ) + ]) + ); + + $overriddenState->add(new Icon('arrow-right')); + $overriddenState->add( + (new StateBall(strtolower($node->getStateName($fakeState)), StateBall::SIZE_MEDIUM)) + ->addAttributes([ + 'title' => sprintf( + '%s', + $node->getStateName($fakeState) + ), + 'class' => 'last' + ]) + ); + + return $overriddenState; + } + + /** + * @param BpConfig $bp + * @param Node $node + * @param array $path + * + * @return string + */ + public function renderNode(BpConfig $bp, Node $node, $path = array()) + { + $htmlId = $this->getId($node, $path); + $li = Html::tag( + 'li', + [ + 'id' => $htmlId, + 'class' => ['bp', 'movable', $node->getObjectClassName()], + 'data-node-name' => $node instanceof ImportedNode + ? $node->getNodeName() + : $node->getName() + ] + ); + $attributes = $li->getAttributes(); + $attributes->add('class', $this->getStateClassNames($node)); + if ($node->isHandled()) { + $attributes->add('class', 'handled'); + } + if ($node instanceof BpNode) { + $attributes->add('class', 'operator'); + } else { + $attributes->add('class', 'node'); + } + + $details = new HtmlElement('details', Attributes::create(['open' => true])); + $summary = new HtmlElement('summary'); + if (version_compare(Version::VERSION, self::NEW_COLLAPSIBLE_IMPLEMENTATION_SINCE, '>=')) { + $details->getAttributes()->add('class', 'collapsible'); + $summary->getAttributes()->add('class', 'collapsible-control'); // Helps JS, improves performance a bit + } + + $summary->addHtml( + new Icon('caret-down', ['class' => 'collapse-icon']), + new Icon('caret-right', ['class' => 'expand-icon']) + ); + + $summary->add($this->getNodeIcons($node, $path)); + + $summary->add(Html::tag('span', null, $node->getAlias())); + + if ($node instanceof BpNode) { + $summary->add(Html::tag('span', ['class' => 'op'], $node->operatorHtml())); + } + + if ($node instanceof BpNode && $node->hasInfoUrl()) { + $summary->add($this->createInfoAction($node)); + } + + $differentConfig = $node->getBpConfig()->getName() !== $this->getBusinessProcess()->getName(); + if (! $this->isLocked() && !$differentConfig) { + $summary->add($this->getActionIcons($bp, $node)); + } elseif ($differentConfig) { + $summary->add($this->actionIcon( + 'share', + $node->getBpConfig()->isFaulty() + ? $this->getBaseUrl()->setParam('config', $node->getBpConfig()->getName()) + : $this->getSourceUrl($node)->addParams(['mode' => 'tree'])->getAbsoluteUrl(), + mt('businessprocess', 'Show this process as part of its original configuration') + )->addAttributes(['data-base-target' => '_next'])); + } + + $ul = Html::tag('ul', [ + 'class' => ['bp', 'sortable'], + 'data-sortable-disabled' => ($this->isLocked() || $differentConfig || $this->appliesCustomSorting()) + ? 'true' + : 'false', + 'data-sortable-invert-swap' => 'true', + 'data-sortable-data-id-attr' => 'id', + 'data-sortable-draggable' => '.movable', + 'data-sortable-direction' => 'vertical', + 'data-sortable-group' => json_encode([ + 'name' => $htmlId, // Unique, so that the function below is the only deciding factor + 'put' => 'function:rowPutAllowed' + ]), + 'data-csrf-token' => CsrfToken::generate(), + 'data-action-url' => $this->getUrl() + ->with([ + 'config' => $node->getBpConfig()->getName(), + 'node' => $node instanceof ImportedNode + ? $node->getNodeName() + : $node->getName() + ]) + ->getAbsoluteUrl() + ]); + + $path[] = $differentConfig ? $node->getIdentifier() : $node->getName(); + foreach ($this->sort($node->getChildren()) as $name => $child) { + if ($child instanceof BpNode) { + $ul->add($this->renderNode($bp, $child, $path)); + } else { + $ul->add($this->renderChild($bp, $node, $child, $path)); + } + } + + $details->addHtml($summary); + $details->addHtml($ul); + $li->addHtml($details); + + return $li; + } + + protected function renderChild($bp, BpNode $parent, Node $node, $path = null) + { + $li = Html::tag('li', [ + 'class' => 'movable', + 'id' => $this->getId($node, $path ?: []), + 'data-node-name' => $node->getName() + ]); + + $li->add($this->getNodeIcons($node, $path, $parent)); + + $link = $node->getLink(); + $link->getAttributes()->set('data-base-target', '_next'); + $li->add($link); + + if (($overriddenState = $parent->getChildState($node)) !== $node->getState()) { + $li->add($this->getOverriddenState($overriddenState, $node)); + } + + if (! $this->isLocked() && $node->getBpConfig()->getName() === $this->getBusinessProcess()->getName()) { + $li->add($this->getActionIcons($bp, $node)); + } + + return $li; + } + + protected function getActionIcons(BpConfig $bp, Node $node) + { + if ($node instanceof BpNode) { + if ($bp->getMetadata()->canModify()) { + return [$this->createEditAction($bp, $node), $this->renderAddNewNode($node)]; + } else { + return ''; + } + } else { + return $this->createSimulationAction($bp, $node); + } + } + + protected function createEditAction(BpConfig $bp, BpNode $node) + { + return $this->actionIcon( + 'edit', + $this->getUrl()->with(array( + 'action' => 'edit', + 'editnode' => $node->getName() + )), + mt('businessprocess', 'Modify this node') + ); + } + + protected function createSimulationAction(BpConfig $bp, Node $node) + { + return $this->actionIcon( + 'wand-magic-sparkles', + $this->getUrl()->with(array( + //'config' => $bp->getName(), + 'action' => 'simulation', + 'simulationnode' => $node->getName() + )), + mt('businessprocess', 'Simulate a specific state') + ); + } + + protected function createInfoAction(BpNode $node) + { + $url = $node->getInfoUrl(); + return $this->actionIcon( + 'question', + $url, + sprintf('%s: %s', mt('businessprocess', 'More information'), $url) + )->addAttributes(['target' => '_blank']); + } + + protected function actionIcon($icon, $url, $title) + { + return Html::tag( + 'a', + [ + 'href' => $url, + 'title' => $title, + 'class' => 'action-link' + ], + new Icon($icon) + ); + } + + protected function renderAddNewNode($parent) + { + return $this->actionIcon( + 'plus', + $this->getUrl() + ->with('action', 'add') + ->with('node', $parent->getName()), + mt('businessprocess', 'Add a new business process node') + ); + } +} diff --git a/library/Businessprocess/ServiceNode.php b/library/Businessprocess/ServiceNode.php new file mode 100644 index 0000000..c80b984 --- /dev/null +++ b/library/Businessprocess/ServiceNode.php @@ -0,0 +1,95 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Module\Businessprocess\Web\Url; +use ipl\I18n\Translation; + +class ServiceNode extends MonitoredNode +{ + use Translation; + + protected $hostname; + + /** @var string Alias of the host */ + protected $hostAlias; + + protected $service; + + protected $className = 'service'; + + protected $icon = 'gear'; + + public function __construct($object) + { + $this->name = BpConfig::joinNodeName($object->hostname, $object->service); + $this->hostname = $object->hostname; + $this->service = $object->service; + if (isset($object->state)) { + $this->setState($object->state); + } else { + $this->setState(0)->setMissing(); + } + } + + public function getHostname() + { + return $this->hostname; + } + + /** + * Get the host alias + * + * @return string + */ + public function getHostAlias() + { + return $this->hostAlias; + } + + /** + * Set the host alias + * + * @param string $hostAlias + * + * @return $this + */ + public function setHostAlias($hostAlias) + { + $this->hostAlias = $hostAlias; + + return $this; + } + + public function getServiceDescription() + { + return $this->service; + } + + public function getAlias() + { + if ($this->getHostAlias() === null || $this->alias === null) { + return null; + } + + return sprintf( + $this->translate('%s on %s', '<service> on <host>'), + $this->alias, + $this->getHostAlias() + ); + } + + public function getUrl() + { + $params = array( + 'host' => $this->getHostname(), + 'service' => $this->getServiceDescription() + ); + + if ($this->getBpConfig()->hasBackendName()) { + $params['backend'] = $this->getBpConfig()->getBackendName(); + } + + return Url::fromPath('businessprocess/service/show', $params); + } +} diff --git a/library/Businessprocess/Simulation.php b/library/Businessprocess/Simulation.php new file mode 100644 index 0000000..1bc9d1d --- /dev/null +++ b/library/Businessprocess/Simulation.php @@ -0,0 +1,185 @@ +<?php + +namespace Icinga\Module\Businessprocess; + +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Session\SessionNamespace; + +class Simulation +{ + const DEFAULT_SESSION_KEY = 'bp-simulations'; + + /** + * @var SessionNamespace + */ + protected $session; + + /** + * @var string + */ + protected $sessionKey; + + /** + * @var array + */ + protected $simulations = array(); + + /** + * Simulation constructor. + * @param array $simulations + */ + public function __construct(array $simulations = array()) + { + $this->simulations = $simulations; + } + + /** + * @param array $simulations + * @return static + */ + public static function create(array $simulations = array()) + { + return new static($simulations); + } + + /** + * @param SessionNamespace $session + * @param null $sessionKey + * @return $this + */ + public static function fromSession(SessionNamespace $session, $sessionKey = null) + { + return static::create() + ->setSessionKey($sessionKey) + ->persistToSession($session); + } + + /** + * @param string $key + * @return $this + */ + public function setSessionKey($key = null) + { + if ($key === null) { + $this->sessionKey = Simulation::DEFAULT_SESSION_KEY; + } else { + $this->sessionKey = $key; + } + + return $this; + } + + /** + * @param SessionNamespace $session + * @return $this + */ + public function persistToSession(SessionNamespace $session) + { + $this->session = $session; + $this->simulations = $this->session->get($this->sessionKey, array()); + return $this; + } + + /** + * @return array + */ + public function simulations() + { + return $this->simulations; + } + + /** + * @param $simulations + * @return $this + */ + protected function setSimulations($simulations) + { + $this->simulations = $simulations; + if ($this->session !== null) { + $this->session->set($this->sessionKey, $simulations); + } + + return $this; + } + + /** + * @return $this + */ + public function clear() + { + $this->simulations = array(); + if ($this->session !== null) { + $this->session->set($this->sessionKey, array()); + } + + return $this; + } + + /** + * @return int + */ + public function count() + { + return count($this->simulations()); + } + + /** + * @return bool + */ + public function isEmpty() + { + return $this->count() === 0; + } + + /** + * @param $node + * @param $properties + */ + public function set($node, $properties) + { + $simulations = $this->simulations(); + $simulations[$node] = $properties; + $this->setSimulations($simulations); + } + + /** + * @param $name + * @return bool + */ + public function hasNode($name) + { + $simulations = $this->simulations(); + return array_key_exists($name, $simulations); + } + + /** + * @param $name + * @return mixed + * @throws ProgrammingError + */ + public function getNode($name) + { + $simulations = $this->simulations(); + if (! array_key_exists($name, $simulations)) { + throw new ProgrammingError('Trying to access invalid node %s', $name); + } + return $simulations[$name]; + } + + /** + * @param $node + * @return bool + */ + public function remove($node) + { + $simulations = $this->simulations(); + if (array_key_exists($node, $simulations)) { + unset($simulations[$node]); + $this->setSimulations($simulations); + + return true; + } else { + return false; + } + } +} diff --git a/library/Businessprocess/State/IcingaDbState.php b/library/Businessprocess/State/IcingaDbState.php new file mode 100644 index 0000000..1a66900 --- /dev/null +++ b/library/Businessprocess/State/IcingaDbState.php @@ -0,0 +1,191 @@ +<?php + +namespace Icinga\Module\Businessprocess\State; + +use Exception; +use Icinga\Application\Benchmark; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\IcingaDbObject; +use Icinga\Module\Businessprocess\ServiceNode; +use Icinga\Module\Icingadb\Common\IcingaRedis; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; +use ipl\Sql\Connection as IcingaDbConnection; +use ipl\Stdlib\Filter; + +class IcingaDbState +{ + /** @var BpConfig */ + protected $config; + + /** @var IcingaDbConnection */ + protected $backend; + + public function __construct(BpConfig $config) + { + $this->config = $config; + $this->backend = IcingaDbObject::fetchDb(); + } + + public static function apply(BpConfig $config) + { + $self = new static($config); + $self->retrieveStatesFromBackend(); + + return $config; + } + + public function retrieveStatesFromBackend() + { + $config = $this->config; + + try { + $this->reallyRetrieveStatesFromBackend(); + } catch (Exception $e) { + $config->addError( + $config->translate('Could not retrieve process state: %s'), + $e->getMessage() + ); + } + } + + public function reallyRetrieveStatesFromBackend() + { + $config = $this->config; + + $involvedHostNames = $config->listInvolvedHostNames(); + if (empty($involvedHostNames)) { + return $this; + } + + Benchmark::measure(sprintf( + 'Retrieving states for business process %s using Icinga DB backend', + $config->getName() + )); + + $hosts = Host::on($this->backend)->columns([ + 'id' => 'host.id', + 'name' => 'host.name', + 'display_name' => 'host.display_name', + 'hard_state' => 'host.state.hard_state', + 'soft_state' => 'host.state.soft_state', + 'last_state_change' => 'host.state.last_state_change', + 'in_downtime' => 'host.state.in_downtime', + 'is_acknowledged' => 'host.state.is_acknowledged' + ])->filter(Filter::equal('host.name', $involvedHostNames)); + + $services = Service::on($this->backend)->columns([ + 'id' => 'service.id', + 'name' => 'service.name', + 'display_name' => 'service.display_name', + 'host_name' => 'host.name', + 'host_display_name' => 'host.display_name', + 'hard_state' => 'service.state.hard_state', + 'soft_state' => 'service.state.soft_state', + 'last_state_change' => 'service.state.last_state_change', + 'in_downtime' => 'service.state.in_downtime', + 'is_acknowledged' => 'service.state.is_acknowledged' + ])->filter(Filter::equal('host.name', $involvedHostNames)); + + // All of this is ipl-sql now, for performance reasons + foreach ($config->listInvolvedConfigs() as $cfg) { + $serviceIds = []; + $serviceResults = []; + foreach ($this->backend->yieldAll($services->assembleSelect()) as $row) { + $row->hex_id = bin2hex(is_resource($row->id) ? stream_get_contents($row->id) : $row->id); + $serviceIds[] = $row->hex_id; + $serviceResults[] = $row; + } + + $redisServiceResults = iterator_to_array(IcingaRedis::fetchServiceState($serviceIds, [ + 'hard_state', + 'soft_state', + 'last_state_change', + 'in_downtime', + 'is_acknowledged' + ])); + foreach ($serviceResults as $row) { + if (isset($redisServiceResults[$row->hex_id])) { + $row = (object) array_merge( + (array) $row, + $redisServiceResults[$row->hex_id] + ); + } + + $this->handleDbRow($row, $cfg, 'service'); + } + + Benchmark::measure('Retrieved states for ' . count($serviceIds) . ' services in ' . $config->getName()); + + $hostIds = []; + $hostResults = []; + foreach ($this->backend->yieldAll($hosts->assembleSelect()) as $row) { + $row->hex_id = bin2hex(is_resource($row->id) ? stream_get_contents($row->id) : $row->id); + $hostIds[] = $row->hex_id; + $hostResults[] = $row; + } + + $redisHostResults = iterator_to_array(IcingaRedis::fetchHostState($hostIds, [ + 'hard_state', + 'soft_state', + 'last_state_change', + 'in_downtime', + 'is_acknowledged' + ])); + foreach ($hostResults as $row) { + if (isset($redisHostResults[$row->hex_id])) { + $row = (object) array_merge( + (array) $row, + $redisHostResults[$row->hex_id] + ); + } + + $this->handleDbRow($row, $cfg, 'host'); + } + + Benchmark::measure('Retrieved states for ' . count($hostIds) . ' hosts in ' . $config->getName()); + } + + Benchmark::measure('Got states for business process ' . $config->getName()); + + return $this; + } + + protected function handleDbRow($row, BpConfig $config, $type) + { + if ($type === 'service') { + $key = BpConfig::joinNodeName($row->host_name, $row->name); + } else { + $key = BpConfig::joinNodeName($row->name, 'Hoststatus'); + } + + // We fetch more states than we need, so skip unknown ones + if (! $config->hasNode($key)) { + return; + } + + $node = $config->getNode($key); + + if ($this->config->usesHardStates()) { + if ($row->hard_state !== null) { + $node->setState($row->hard_state)->setMissing(false); + } + } else { + if ($row->soft_state !== null) { + $node->setState($row->soft_state)->setMissing(false); + } + } + + if ($row->last_state_change !== null) { + $node->setLastStateChange($row->last_state_change / 1000.0); + } + + $node->setDowntime($row->in_downtime === 'y'); + $node->setAck($row->is_acknowledged === 'y'); + $node->setAlias($row->display_name); + + if ($node instanceof ServiceNode) { + $node->setHostAlias($row->host_display_name); + } + } +} diff --git a/library/Businessprocess/State/MonitoringState.php b/library/Businessprocess/State/MonitoringState.php new file mode 100644 index 0000000..b6a2391 --- /dev/null +++ b/library/Businessprocess/State/MonitoringState.php @@ -0,0 +1,151 @@ +<?php + +namespace Icinga\Module\Businessprocess\State; + +use Exception; +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\ServiceNode; +use Icinga\Module\Monitoring\Backend\MonitoringBackend; + +class MonitoringState +{ + /** @var BpConfig */ + protected $config; + + /** @var MonitoringBackend */ + protected $backend; + + private function __construct(BpConfig $config) + { + $this->config = $config; + $this->backend = $config->getBackend(); + } + + public static function apply(BpConfig $config) + { + $self = new static($config); + $self->retrieveStatesFromBackend(); + return $config; + } + + public function retrieveStatesFromBackend() + { + $config = $this->config; + + try { + $this->reallyRetrieveStatesFromBackend(); + } catch (Exception $e) { + $config->addError( + $config->translate('Could not retrieve process state: %s'), + $e->getMessage() + ); + } + } + + public function reallyRetrieveStatesFromBackend() + { + $config = $this->config; + + Benchmark::measure('Retrieving states for business process ' . $config->getName()); + $backend = $this->backend; + + if ($config->usesHardStates()) { + $hostStateColumn = 'host_hard_state'; + $hostStateChangeColumn = 'host_last_hard_state_change'; + $serviceStateColumn = 'service_hard_state'; + $serviceStateChangeColumn = 'service_last_hard_state_change'; + } else { + $hostStateColumn = 'host_state'; + $hostStateChangeColumn = 'host_last_state_change'; + $serviceStateColumn = 'service_state'; + $serviceStateChangeColumn = 'service_last_state_change'; + } + + $hosts = $config->listInvolvedHostNames(); + if (empty($hosts)) { + return $this; + } + + $hostFilter = Filter::expression('host_name', '=', $hosts); + + $hostStatus = $backend->select()->from('hostStatus', array( + 'hostname' => 'host_name', + 'last_state_change' => $hostStateChangeColumn, + 'in_downtime' => 'host_in_downtime', + 'ack' => 'host_acknowledged', + 'state' => $hostStateColumn, + 'display_name' => 'host_display_name' + ))->applyFilter($hostFilter)->getQuery()->fetchAll(); + + Benchmark::measure('Retrieved states for ' . count($hostStatus) . ' hosts in ' . $config->getName()); + + // NOTE: we intentionally filter by host_name ONLY + // Tests with host IN ... AND service IN shows longer query times + // while retrieving 1635 (in 5ms) vs. 1388 (in ~430ms) services + $serviceStatus = $backend->select()->from('serviceStatus', array( + 'hostname' => 'host_name', + 'service' => 'service_description', + 'last_state_change' => $serviceStateChangeColumn, + 'in_downtime' => 'service_in_downtime', + 'ack' => 'service_acknowledged', + 'state' => $serviceStateColumn, + 'display_name' => 'service_display_name', + 'host_display_name' => 'host_display_name' + ))->applyFilter($hostFilter)->getQuery()->fetchAll(); + + Benchmark::measure('Retrieved states for ' . count($serviceStatus) . ' services in ' . $config->getName()); + + $configs = $config->listInvolvedConfigs(); + foreach ($configs as $cfg) { + foreach ($serviceStatus as $row) { + $this->handleDbRow($row, $cfg); + } + foreach ($hostStatus as $row) { + $this->handleDbRow($row, $cfg); + } + } + + // TODO: Union, single query? + Benchmark::measure('Got states for business process ' . $config->getName()); + + return $this; + } + + protected function handleDbRow($row, BpConfig $config) + { + $key = BpConfig::joinNodeName( + $row->hostname, + property_exists($row, 'service') + ? $row->service + : 'Hoststatus' + ); + + // We fetch more states than we need, so skip unknown ones + if (! $config->hasNode($key)) { + return; + } + + $node = $config->getNode($key); + + if ($row->state !== null) { + $node->setState($row->state)->setMissing(false); + } + if ($row->last_state_change !== null) { + $node->setLastStateChange($row->last_state_change); + } + if ((int) $row->in_downtime === 1) { + $node->setDowntime(true); + } + if ((int) $row->ack === 1) { + $node->setAck(true); + } + + $node->setAlias($row->display_name); + + if ($node instanceof ServiceNode) { + $node->setHostAlias($row->host_display_name); + } + } +} diff --git a/library/Businessprocess/Storage/ConfigDiff.php b/library/Businessprocess/Storage/ConfigDiff.php new file mode 100644 index 0000000..133cfb7 --- /dev/null +++ b/library/Businessprocess/Storage/ConfigDiff.php @@ -0,0 +1,77 @@ +<?php + +namespace Icinga\Module\Businessprocess\Storage; + +use ipl\Html\ValidHtml; +use Jfcherng\Diff\Differ; +use Jfcherng\Diff\Factory\RendererFactory; + +class ConfigDiff implements ValidHtml +{ + protected $a; + + protected $b; + + protected $diff; + protected $opcodes; + + protected function __construct($a, $b) + { + if (empty($a)) { + $this->a = array(); + } else { + $this->a = explode("\n", (string) $a); + } + + if (empty($b)) { + $this->b = array(); + } else { + $this->b = explode("\n", (string) $b); + } + + $options = array( + 'context' => 5, + // 'ignoreWhitespace' => true, + // 'ignoreCase' => true, + ); + $this->diff = new Differ($this->a, $this->b, $options); + } + + /** + * @return string + */ + public function render() + { + return $this->renderHtmlSideBySide(); + } + + public function renderHtmlSideBySide() + { + $renderer = RendererFactory::make('SideBySide'); + return $renderer->render($this->diff); + } + + public function renderHtmlInline() + { + $renderer = RendererFactory::make('Inline'); + return $renderer->render($this->diff); + } + + public function renderTextContext() + { + $renderer = RendererFactory::make('Context'); + return $renderer->render($this->diff); + } + + public function renderTextUnified() + { + $renderer = RendererFactory::make('Unified'); + return $renderer->render($this->diff); + } + + public static function create($a, $b) + { + $diff = new static($a, $b); + return $diff; + } +} diff --git a/library/Businessprocess/Storage/LegacyConfigParser.php b/library/Businessprocess/Storage/LegacyConfigParser.php new file mode 100644 index 0000000..754c7ff --- /dev/null +++ b/library/Businessprocess/Storage/LegacyConfigParser.php @@ -0,0 +1,413 @@ +<?php + +namespace Icinga\Module\Businessprocess\Storage; + +use Icinga\Application\Benchmark; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\SystemPermissionException; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\Metadata; + +class LegacyConfigParser +{ + /** @var ?string */ + protected static $prevKey; + + /** @var int */ + protected $currentLineNumber; + + /** @var string */ + protected $currentFilename; + + protected $name; + + /** @var BpConfig */ + protected $config; + + /** @var array */ + protected $missingNodes = []; + + /** + * LegacyConfigParser constructor + * + * @param $name + */ + private function __construct($name) + { + $this->name = $name; + $this->config = new BpConfig(); + $this->config->setName($name); + } + + /** + * @return BpConfig + */ + public function getParsedConfig() + { + return $this->config; + } + + /** + * @param $name + * @param $filename + * + * @return BpConfig + */ + public static function parseFile($name, $filename) + { + Benchmark::measure('Loading business process ' . $name); + $parser = new static($name); + $parser->reallyParseFile($filename); + Benchmark::measure('Business process ' . $name . ' loaded'); + return $parser->getParsedConfig(); + } + + /** + * @param $name + * @param $string + * + * @return BpConfig + */ + public static function parseString($name, $string) + { + Benchmark::measure('Loading BP config from file: ' . $name); + $parser = new static($name); + + $config = $parser->getParsedConfig(); + $config->setMetadata( + static::readMetadataFromString($name, $string) + ); + + foreach (preg_split('/\r?\n/', $string) as $line) { + $parser->parseLine($line); + } + + $parser->resolveMissingNodes(); + + Benchmark::measure('Business process ' . $name . ' loaded'); + return $config; + } + + protected function reallyParseFile($filename) + { + $file = $this->currentFilename = $filename; + $fh = @fopen($file, 'r'); + if (! $fh) { + throw new SystemPermissionException('Could not open "%s"', $filename); + } + + $config = $this->config; + $config->setMetadata( + $this::readMetadataFromFileHeader($config->getName(), $filename) + ); + + $this->currentLineNumber = 0; + while ($line = fgets($fh)) { + $this->parseLine($line); + } + + $this->resolveMissingNodes(); + + fclose($fh); + unset($this->currentLineNumber); + unset($this->currentFilename); + } + + /** + * Resolve previously missed business process nodes + * + * @throws ConfigurationError In case a referenced process does not exist + */ + protected function resolveMissingNodes() + { + foreach ($this->missingNodes as $name => $parents) { + foreach ($parents as $parent) { + /** @var BpNode $parent */ + $parent->addChild($this->config->getNode($name)); + } + } + } + + public static function readMetadataFromFileHeader($name, $filename) + { + $metadata = new Metadata($name); + $fh = fopen($filename, 'r'); + $cnt = 0; + static::$prevKey = null; + while ($cnt < 15 && false !== ($line = fgets($fh))) { + $cnt++; + static::parseHeaderLine($line, $metadata); + } + + fclose($fh); + return $metadata; + } + + public static function readMetadataFromString($name, &$string) + { + $metadata = new Metadata($name); + + $lines = preg_split('/\r?\n/', substr($string, 0, 8092)); + static::$prevKey = null; + + foreach ($lines as $line) { + static::parseHeaderLine($line, $metadata); + } + + return $metadata; + } + + protected function splitCommaSeparated($string) + { + return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY); + } + + protected function readHeaderString($string, Metadata $metadata) + { + foreach (preg_split('/\r?\n/', $string) as $line) { + $this->parseHeaderLine($line, $metadata); + } + + return $metadata; + } + + /** + * @return array + */ + protected function emptyHeader() + { + return array( + 'Title' => null, + 'Description' => null, + 'Owner' => null, + 'AllowedUsers' => null, + 'AllowedGroups' => null, + 'AllowedRoles' => null, + 'Backend' => null, + 'Statetype' => 'soft', + 'SLAHosts' => null + ); + } + + /** + * @param $line + * @param Metadata $metadata + */ + protected static function parseHeaderLine($line, Metadata $metadata) + { + if (empty($line)) { + return; + } + + if (preg_match('/^\s*#\s+(.+?)\s*:\s*(.+)$/', trim($line), $m)) { + if ($metadata->hasKey($m[1])) { + static::$prevKey = $m[1]; + $metadata->set($m[1], $m[2]); + } + } elseif ($line[0] === '#') { + $line = ltrim($line, "#"); + + // Check if the line is from the multi-line comment and parse it accordingly + if (trim($line) !== '' && ! preg_match('/^\s*(.+?)\s*:$/', trim($line), $m) && static::$prevKey) { + $line = trim( + substr( + $line, + strlen(sprintf("%-15s :", static::$prevKey)) + 2 + ), + "\n\r" + ); + + $description = $metadata->get(static::$prevKey) . "\n" . $line; + $metadata->set(static::$prevKey, $description); + } + } + } + + /** + * @param $line + * @param BpConfig $bp + */ + protected function parseDisplay(&$line, BpConfig $bp) + { + list($display, $name, $desc) = preg_split('~\s*(?<!\\\\);\s*~', substr($line, 8), 3); + $bp->getBpNode($name)->setAlias($desc)->setDisplay($display); + if ($display > 0) { + $bp->addRootNode($name); + } + } + + protected function parseInfoUrl(&$line, BpConfig $bp) + { + list($name, $url) = preg_split('~\s*(?<!\\\\);\s*~', substr($line, 9), 2); + $bp->getBpNode($name)->setInfoUrl($url); + } + + protected function parseStateOverrides(&$line, BpConfig $bp) + { + // state_overrides <bp-node>!<child>|n-n[,n-n]!<child>|n-n[,n-n] + $segments = preg_split('~\s*!\s*~', substr($line, 16)); + /** @var BpNode $node */ + $node = $bp->getNode(array_shift($segments)); + foreach ($segments as $overrideDef) { + list($childName, $overrides) = preg_split('~\s*\|\s*~', $overrideDef, 2); + + $stateOverrides = []; + foreach (preg_split('~\s*,\s*~', $overrides) as $override) { + list($from, $to) = preg_split('~\s*-\s*~', $override, 2); + $stateOverrides[(int) $from] = (int) $to; + } + + $node->setStateOverrides($stateOverrides, $childName); + } + } + + protected function parseExtraLine(&$line, $typeLength, BpConfig $bp) + { + $type = substr($line, 0, $typeLength); + if (substr($type, 0, 7) === 'display') { + $this->parseDisplay($line, $bp); + return true; + } + + switch ($type) { + case 'external_info': + case 'extra_info': + break; + case 'info_url': + $this->parseInfoUrl($line, $bp); + break; + case 'state_overrides': + $this->parseStateOverrides($line, $bp); + break; + case 'template': + // compat, ignoring for now + break; + default: + return false; + } + + return true; + } + + /** + * Parses a single line + * + * Adds eventual new knowledge to the given Business Process config + * + * @param $line + * + * @throws ConfigurationError + */ + protected function parseLine(&$line) + { + $bp = $this->config; + $line = trim($line); + + $this->currentLineNumber++; + + // Skip empty or comment-only lines + if (empty($line) || $line[0] === '#') { + return; + } + + // Space found in the first 16 cols? Might be a line with extra information + $pos = strpos($line, ' '); + if ($pos !== false && $pos < 16) { + if ($this->parseExtraLine($line, $pos, $bp)) { + return; + } + } + + if (strpos($line, '=') === false) { + $this->parseError('Got invalid line'); + } + + list($name, $value) = preg_split('~\s*=\s*~', $line, 2); + + $op = '&'; + if (preg_match_all('~(?<!\\\\)([\|\+&\!\%\^])~', $value, $m)) { + $op = implode('', $m[1]); + for ($i = 1; $i < strlen($op); $i++) { + if ($op[$i] !== $op[$i - 1]) { + $this->parseError('Mixing operators is not allowed'); + } + } + } + $op = $op[0]; + $op_name = $op; + + if ($op === '+') { + if (! preg_match('~^(\d+)(?::(\d+))?\s*of:\s*(.+?)$~', $value, $m)) { + $this->parseError('syntax: <var> = <num> of: <var1> + <var2> [+ <varn>]*'); + } + $op_name = $m[1]; + // New feature: $minWarn = $m[2]; + $value = $m[3]; + } + + $node = new BpNode((object) array( + 'name' => $name, + 'operator' => $op_name, + 'child_names' => [] + )); + $node->setBpConfig($bp); + + $cmps = preg_split('~\s*(?<!\\\\)\\' . $op . '\s*~', $value, -1, PREG_SPLIT_NO_EMPTY); + foreach ($cmps as $val) { + $val = preg_replace('~(\\\\([\|\+&\!\%\^]))~', '$2', $val); + if (preg_match('~(?<!\\\\);~', $val)) { + if ($bp->hasNode($val)) { + $node->addChild($bp->getNode($val)); + } else { + list($host, $service) = preg_split('~(?<!\\\\);~', $val, 2); + if ($service === 'Hoststatus') { + $node->addChild($bp->createHost(str_replace('\\;', ';', $host))); + } else { + $node->addChild($bp->createService(str_replace('\\;', ';', $host), $service)); + } + } + } elseif ($val[0] === '@') { + if (strpos($val, ':') === false) { + throw new ConfigurationError( + "I'm unable to import full external configs, a node needs to be provided for '%s'", + $val + ); + } else { + list($config, $nodeName) = preg_split('~:\s*~', substr($val, 1), 2); + $node->addChild($bp->createImportedNode($config, $nodeName)); + } + } elseif ($bp->hasNode($val)) { + $node->addChild($bp->getNode($val)); + } else { + $this->missingNodes[$val][] = $node; + } + } + + $bp->addNode($name, $node); + } + + /** + * @return string + */ + public function getFilename() + { + return $this->currentFilename ?: '[given string]'; + } + + /** + * @param $msg + * @throws ConfigurationError + */ + protected function parseError($msg) + { + throw new ConfigurationError( + sprintf( + 'Parse error on %s:%s: %s', + $this->getFilename(), + $this->currentLineNumber, + $msg + ) + ); + } +} diff --git a/library/Businessprocess/Storage/LegacyConfigRenderer.php b/library/Businessprocess/Storage/LegacyConfigRenderer.php new file mode 100644 index 0000000..1f7e23b --- /dev/null +++ b/library/Businessprocess/Storage/LegacyConfigRenderer.php @@ -0,0 +1,268 @@ +<?php + +namespace Icinga\Module\Businessprocess\Storage; + +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\ImportedNode; + +class LegacyConfigRenderer +{ + /** @var array */ + protected $renderedNodes; + + protected $config; + + /** + * LecagyConfigRenderer constructor + * + * @param BpConfig $config + */ + public function __construct(BpConfig $config) + { + $this->config = $config; + } + + /** + * @return string + */ + public function render() + { + return $this->renderHeader() . $this->renderNodes(); + } + + /** + * @param BpConfig $config + * @return mixed + */ + public static function renderConfig(BpConfig $config) + { + $renderer = new static($config); + return $renderer->render(); + } + + /** + * @return string + */ + public function renderHeader() + { + $str = "### Business Process Config File ###\n#\n"; + + $meta = $this->config->getMetadata(); + foreach ($meta->getProperties() as $key => $value) { + if ($value === null) { + continue; + } + + $lineNum = 1; + $spaces = str_repeat(' ', strlen(sprintf("%-15s :", $key))); + + foreach (preg_split('/\r?\n/', $value) as $line) { + if ($lineNum === 1) { + $str .= sprintf("# %-15s : %s\n", $key, $line); + } else { + $str .= sprintf("# %s %s\n", $spaces, $line); + } + + $lineNum++; + } + } + + $str .= "#\n###################################\n\n"; + + return $str; + } + + /** + * @return string + */ + public function renderNodes() + { + $this->renderedNodes = array(); + + $config = $this->config; + $str = ''; + + foreach ($config->getRootNodes() as $node) { + $str .= $this->requireRenderedBpNode($node); + } + + foreach ($config->getUnboundNodes() as $name => $node) { + $str .= $this->requireRenderedBpNode($node); + } + + return $str . "\n"; + } + + /** + * Rendered node definition, empty string if already rendered + * + * @param BpNode $node + * + * @return string + */ + protected function requireRenderedBpNode(BpNode $node) + { + $name = $node->getName(); + + if (array_key_exists($name, $this->renderedNodes)) { + return ''; + } else { + $this->renderedNodes[$name] = true; + return $this->renderBpNode($node); + } + } + + /** + * @param BpNode $node + * @return string + */ + protected function renderBpNode(BpNode $node) + { + $name = $node->getName(); + // Doing this before rendering children allows us to store loops + $cfg = ''; + + foreach ($node->getChildBpNodes() as $name => $child) { + if ($child instanceof ImportedNode) { + continue; + } + + $cfg .= $this->requireRenderedBpNode($child) . "\n"; + } + + $cfg .= static::renderSingleBpNode($node); + + return $cfg; + } + + /** + * @param BpNode $node + * @return string + */ + public static function renderEqualSign(BpNode $node) + { + $op = $node->getOperator(); + if (is_numeric($op)) { + return '= ' . $op . ' of:'; + } else { + return '='; + } + } + + /** + * @param BpNode $node + * @return string + */ + public static function renderOperator(BpNode $node) + { + $op = $node->getOperator(); + if (is_numeric($op)) { + return '+'; + } else { + return $op; + } + } + + /** + * @param BpNode $node + * @return string + */ + public static function renderSingleBpNode(BpNode $node) + { + return static::renderExpression($node) + . static::renderStateOverrides($node) + . static::renderDisplay($node) + . static::renderInfoUrl($node); + } + + /** + * @param BpNode $node + * @return string + */ + public static function renderExpression(BpNode $node) + { + return sprintf( + "%s %s %s\n", + $node->getName(), + static::renderEqualSign($node), + static::renderChildNames($node) + ); + } + + /** + * @param BpNode $node + * @return string + */ + public static function renderChildNames(BpNode $node) + { + $op = static::renderOperator($node); + $children = $node->getChildNames(); + $str = implode(' ' . $op . ' ', array_map(function ($val) { + return preg_replace('~([\|\+&\!\%\^])~', '\\\\$1', $val); + }, $children)); + + if ((count($children) < 2) && $op !== '&') { + return $op . ' ' . $str; + } else { + return $str; + } + } + + /** + * @param BpNode $node + * @return string + */ + public static function renderDisplay(BpNode $node) + { + if ($node->hasAlias() || $node->getDisplay() > 0) { + $prio = $node->getDisplay(); + return sprintf( + "display %s;%s;%s\n", + $prio, + $node->getName(), + $node->getAlias() + ); + } else { + return ''; + } + } + + public static function renderStateOverrides(BpNode $node) + { + $stateOverrides = ''; + foreach ($node->getStateOverrides() as $childName => $overrideRules) { + $overrides = []; + foreach ($overrideRules as $from => $to) { + $overrides[] = sprintf('%d-%d', $from, $to); + } + + if (! empty($overrides)) { + $stateOverrides .= '!' . $childName . '|' . join(',', $overrides); + } + } + + if (! $stateOverrides) { + return ''; + } + + return 'state_overrides ' . $node->getName() . $stateOverrides . "\n"; + } + + /** + * @param BpNode $node + * @return string + */ + public static function renderInfoUrl(BpNode $node) + { + if ($node->hasInfoUrl()) { + return sprintf( + "info_url %s;%s\n", + $node->getName(), + $node->getInfoUrl() + ); + } else { + return ''; + } + } +} diff --git a/library/Businessprocess/Storage/LegacyStorage.php b/library/Businessprocess/Storage/LegacyStorage.php new file mode 100644 index 0000000..f6cf1e5 --- /dev/null +++ b/library/Businessprocess/Storage/LegacyStorage.php @@ -0,0 +1,205 @@ +<?php + +namespace Icinga\Module\Businessprocess\Storage; + +use DirectoryIterator; +use Icinga\Application\Hook\AuditHook; +use Icinga\Application\Icinga; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Exception\SystemPermissionException; + +class LegacyStorage extends Storage +{ + /** + * All parsed configurations + * + * @var BpConfig[] + */ + protected $configs = []; + + /** @var string */ + protected $configDir; + + public function getConfigDir() + { + if ($this->configDir === null) { + $this->prepareDefaultConfigDir(); + } + + return $this->configDir; + } + + protected function prepareDefaultConfigDir() + { + $dir = Icinga::app() + ->getModuleManager() + ->getModule('businessprocess') + ->getConfigDir(); + + // TODO: This is silly. We need Config::requireDirectory(). + if (! is_dir($dir)) { + if (! is_dir(dirname($dir))) { + if (! @mkdir(dirname($dir))) { + throw new SystemPermissionException('Could not create config directory "%s"', dirname($dir)); + } + } + if (! mkdir($dir)) { + throw new SystemPermissionException('Could not create config directory "%s"', $dir); + } + } + $dir = $dir . '/processes'; + if (! is_dir($dir)) { + if (! mkdir($dir)) { + throw new SystemPermissionException('Could not create config directory "%s"', $dir); + } + } + + $this->configDir = $dir; + } + + /** + * @inheritdoc + */ + public function listProcesses() + { + $files = array(); + + foreach ($this->listAllProcessNames() as $name) { + $meta = $this->loadMetadata($name); + if (! $meta->canRead()) { + continue; + } + + $files[$name] = $meta->getExtendedTitle(); + } + + return $files; + } + + /** + * @inheritdoc + */ + public function listProcessNames() + { + $files = array(); + + foreach ($this->listAllProcessNames() as $name) { + $meta = $this->loadMetadata($name); + if (! $meta->canRead()) { + continue; + } + + $files[$name] = $name; + } + + return $files; + } + + /** + * @inheritdoc + */ + public function listAllProcessNames() + { + $files = array(); + + foreach (new DirectoryIterator($this->getConfigDir()) as $file) { + if ($file->isDot()) { + continue; + } + + $filename = $file->getFilename(); + if (substr($filename, -5) === '.conf') { + $files[] = substr($filename, 0, -5); + } + } + + natcasesort($files); + return $files; + } + + /** + * @inheritdoc + */ + public function loadProcess($name) + { + if (! isset($this->configs[$name])) { + $this->configs[$name] = LegacyConfigParser::parseFile( + $name, + $this->getFilename($name) + ); + } + + return $this->configs[$name]; + } + + /** + * @inheritdoc + */ + public function storeProcess(BpConfig $process) + { + AuditHook::logActivity('businessprocess/store', "Business Process \"{$process->getName()}\" stored"); + file_put_contents( + $this->getFilename($process->getName()), + LegacyConfigRenderer::renderConfig($process) + ); + } + + /** + * @inheritdoc + */ + public function deleteProcess($name) + { + AuditHook::logActivity('businessprocess/delete', "Business Process \"{$name}\" deleted"); + return @unlink($this->getFilename($name)); + } + + /** + * @inheritdoc + */ + public function loadMetadata($name) + { + if (isset($this->configs[$name])) { + return $this->configs[$name]->getMetadata(); + } + + return LegacyConfigParser::readMetadataFromFileHeader( + $name, + $this->getFilename($name) + ); + } + + public function getSource($name) + { + return file_get_contents($this->getFilename($name)); + } + + public function getFilename($name) + { + return $this->getConfigDir() . '/' . $name . '.conf'; + } + + /** + * @param $name + * @param $string + * + * @return BpConfig + */ + public function loadFromString($name, $string) + { + return LegacyConfigParser::parseString($name, $string); + } + + /** + * @param $name + * @return bool + */ + public function hasProcess($name) + { + $file = $this->getFilename($name); + if (! is_file($file)) { + return false; + } + + return $this->loadMetadata($name)->canRead(); + } +} diff --git a/library/Businessprocess/Storage/Storage.php b/library/Businessprocess/Storage/Storage.php new file mode 100644 index 0000000..c8a07ba --- /dev/null +++ b/library/Businessprocess/Storage/Storage.php @@ -0,0 +1,107 @@ +<?php + +namespace Icinga\Module\Businessprocess\Storage; + +use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Metadata; + +abstract class Storage +{ + /** + * @var static + */ + protected static $instance; + + /** + * @var ConfigObject + */ + protected $config; + + /** + * Storage constructor. + * @param ConfigObject $config + */ + public function __construct(ConfigObject $config) + { + $this->config = $config; + $this->init(); + } + + protected function init() + { + } + + public static function getInstance() + { + if (static::$instance === null) { + static::$instance = new static(Config::module('businessprocess')->getSection('global')); + } + + return static::$instance; + } + + /** + * All processes readable by the current user + * + * The returned array has the form <process name> => <nice title>, sorted + * by title + * + * @return array + */ + abstract public function listProcesses(); + + /** + * All process names readable by the current user + * + * The returned array has the form <process name> => <process name> and is + * sorted + * + * @return array + */ + abstract public function listProcessNames(); + + /** + * All available process names, regardless of eventual restrictions + * + * @return array + */ + abstract public function listAllProcessNames(); + + /** + * Whether a configuration with the given name exists + * + * @param $name + * + * @return bool + */ + abstract public function hasProcess($name); + + /** + * @param $name + * @return BpConfig + */ + abstract public function loadProcess($name); + + /** + * Store eventual changes applied to the given configuration + * + * @param BpConfig $config + * + * @return mixed + */ + abstract public function storeProcess(BpConfig $config); + + /** + * @param $name + * @return bool Whether the process has been deleted + */ + abstract public function deleteProcess($name); + + /** + * @param string $name + * @return Metadata + */ + abstract public function loadMetadata($name); +} diff --git a/library/Businessprocess/Test/BaseTestCase.php b/library/Businessprocess/Test/BaseTestCase.php new file mode 100644 index 0000000..ba32b7c --- /dev/null +++ b/library/Businessprocess/Test/BaseTestCase.php @@ -0,0 +1,76 @@ +<?php + +namespace Icinga\Module\Businessprocess\Test; + +use Icinga\Application\Config; +use Icinga\Application\ApplicationBootstrap; +use Icinga\Application\Icinga; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Storage\LegacyStorage; + +abstract class BaseTestCase extends \Icinga\Test\BaseTestCase +{ + /** @var ApplicationBootstrap */ + private static $app; + + public function setUp(): void + { + parent::setUp(); + + $this->getRequestMock()->shouldReceive('getBaseUrl')->andReturn('/icingaweb2/'); + + $this->app() + ->getModuleManager() + ->loadModule('businessprocess'); + } + + protected function emptyConfigSection() + { + return Config::module('businessprocess')->getSection('global'); + } + + /*** + * @return BpConfig + */ + protected function makeLoop() + { + return $this->makeInstance()->loadFromString( + 'loop', + "a = b\nb = c\nc = a\nd = a" + ); + } + + /** + * @return LegacyStorage + */ + protected function makeInstance() + { + return new LegacyStorage($this->emptyConfigSection()); + } + + /** + * @param ?string $subDir + * @return string + */ + protected function getTestsBaseDir($subDir = null) + { + $dir = dirname(dirname(dirname(__DIR__))) . '/test'; + if ($subDir === null) { + return $dir; + } else { + return $dir . '/' . ltrim($subDir, '/'); + } + } + + /** + * @return ApplicationBootstrap + */ + protected function app() + { + if (self::$app === null) { + self::$app = Icinga::app(); + } + + return self::$app; + } +} diff --git a/library/Businessprocess/Test/Bootstrap.php b/library/Businessprocess/Test/Bootstrap.php new file mode 100644 index 0000000..4141c16 --- /dev/null +++ b/library/Businessprocess/Test/Bootstrap.php @@ -0,0 +1,29 @@ +<?php + +namespace Icinga\Module\Businessprocess\Test; + +use Icinga\Application\Cli; + +class Bootstrap +{ + public static function cli($basedir = null) + { + error_reporting(E_ALL | E_STRICT); + if ($basedir === null) { + $basedir = dirname(dirname(dirname(__DIR__))); + } + $testsDir = $basedir . '/test'; + require_once 'Icinga/Application/Cli.php'; + + if (array_key_exists('ICINGAWEB_CONFIGDIR', $_SERVER)) { + $configDir = $_SERVER['ICINGAWEB_CONFIGDIR']; + } else { + $configDir = $testsDir . '/config'; + } + + Cli::start($testsDir, $configDir) + ->getModuleManager() + ->loadModule('ipl', $basedir . '/vendor/ipl') + ->loadModule('businessprocess', $basedir); + } +} diff --git a/library/Businessprocess/Web/Component/ActionBar.php b/library/Businessprocess/Web/Component/ActionBar.php new file mode 100644 index 0000000..94458dc --- /dev/null +++ b/library/Businessprocess/Web/Component/ActionBar.php @@ -0,0 +1,15 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use ipl\Html\BaseHtmlElement; + +class ActionBar extends BaseHtmlElement +{ + protected $contentSeparator = ' '; + + /** @var string */ + protected $tag = 'div'; + + protected $defaultAttributes = array('class' => 'action-bar'); +} diff --git a/library/Businessprocess/Web/Component/BpDashboardTile.php b/library/Businessprocess/Web/Component/BpDashboardTile.php new file mode 100644 index 0000000..9a4a0f6 --- /dev/null +++ b/library/Businessprocess/Web/Component/BpDashboardTile.php @@ -0,0 +1,47 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use Icinga\Module\Businessprocess\BpConfig; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\Text; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +class BpDashboardTile extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'dashboard-tile']; + + public function __construct(BpConfig $bp, $title, $description, $icon, $url, $urlParams = null, $attributes = null) + { + $this->add(Html::tag( + 'div', + ['class' => 'bp-link', 'data-base-target' => '_main'], + (new Link(new Icon($icon), Url::fromPath($url, $urlParams ?: []), $attributes)) + ->add(Html::tag('span', ['class' => 'header'], $title)) + ->add($description) + )); + + $tiles = Html::tag('div', ['class' => 'bp-root-tiles']); + + foreach ($bp->getChildren() as $node) { + $state = strtolower($node->getStateName()); + + $tiles->add(Html::tag( + 'a', + [ + 'href' => Url::fromPath($url, $urlParams ?: [])->with(['node' => $node->getName()]), + 'class' => "badge state-{$state}", + 'title' => $node->getAlias() + ], + Text::create(' ')->setEscaped() + )); + } + + $this->add($tiles); + } +} diff --git a/library/Businessprocess/Web/Component/Content.php b/library/Businessprocess/Web/Component/Content.php new file mode 100644 index 0000000..6d14197 --- /dev/null +++ b/library/Businessprocess/Web/Component/Content.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use ipl\Html\BaseHtmlElement; + +class Content extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $contentSeparator = "\n"; + + protected $defaultAttributes = array('class' => 'content'); +} diff --git a/library/Businessprocess/Web/Component/Controls.php b/library/Businessprocess/Web/Component/Controls.php new file mode 100644 index 0000000..259cbbb --- /dev/null +++ b/library/Businessprocess/Web/Component/Controls.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use ipl\Html\BaseHtmlElement; + +class Controls extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $contentSeparator = "\n"; + + protected $defaultAttributes = array('class' => 'controls'); +} diff --git a/library/Businessprocess/Web/Component/Dashboard.php b/library/Businessprocess/Web/Component/Dashboard.php new file mode 100644 index 0000000..d211772 --- /dev/null +++ b/library/Businessprocess/Web/Component/Dashboard.php @@ -0,0 +1,140 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use Exception; +use Icinga\Application\Modules\Module; +use Icinga\Authentication\Auth; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport; +use Icinga\Module\Businessprocess\State\IcingaDbState; +use Icinga\Module\Businessprocess\State\MonitoringState; +use Icinga\Module\Businessprocess\Storage\Storage; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class Dashboard extends BaseHtmlElement +{ + /** @var string */ + protected $contentSeparator = "\n"; + + /** @var string */ + protected $tag = 'div'; + + protected $defaultAttributes = array( + 'class' => 'overview-dashboard', + 'data-base-target' => '_next' + ); + + /** @var Auth */ + protected $auth; + + /** @var Storage */ + protected $storage; + + /** + * Dashboard constructor. + * @param Auth $auth + * @param Storage $storage + */ + protected function __construct(Auth $auth, Storage $storage) + { + $this->auth = $auth; + $this->storage = $storage; + // TODO: Auth? + $processes = $storage->listProcessNames(); + $this->add( + Html::tag('h1', null, mt('businessprocess', 'Welcome to your Business Process Overview')) + ); + $this->add(Html::tag( + 'p', + null, + mt( + 'businessprocess', + 'From here you can reach all your defined Business Process' + . ' configurations, create new or modify existing ones' + ) + )); + if ($auth->hasPermission('businessprocess/create')) { + $this->add( + new DashboardAction( + mt('businessprocess', 'Create'), + mt('businessprocess', 'Create a new Business Process configuration'), + 'plus', + 'businessprocess/process/create', + null, + array('class' => 'addnew') + ) + )->add( + new DashboardAction( + mt('businessprocess', 'Upload'), + mt('businessprocess', 'Upload an existing Business Process configuration'), + 'upload', + 'businessprocess/process/upload', + null, + array('class' => 'addnew') + ) + ); + } elseif (empty($processes)) { + $this->add( + Html::tag('div') + ->add(Html::tag('h1', null, mt('businessprocess', 'Not available'))) + ->add(Html::tag('p', null, mt( + 'businessprocess', + 'No Business Process has been defined for you' + ))) + ); + } + + foreach ($processes as $name) { + $meta = $storage->loadMetadata($name); + $title = $meta->get('Title'); + + if ($title === null) { + $title = $name; + } + + try { + $bp = $storage->loadProcess($name); + } catch (Exception $e) { + $this->add(new BpDashboardTile( + new BpConfig(), + $title, + sprintf(t('File %s has faulty config'), $name . '.conf'), + 'file-circle-xmark', + 'businessprocess/process/show', + ['config' => $name] + )); + + continue; + } + + if (Module::exists('icingadb') && + (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend()) + ) { + IcingaDbState::apply($bp); + } else { + MonitoringState::apply($bp); + } + + $this->add(new BpDashboardTile( + $bp, + $title, + $meta->get('Description'), + 'sitemap', + 'businessprocess/process/show', + array('config' => $name) + )); + } + } + + /** + * @param Auth $auth + * @param Storage $storage + * @return static + */ + public static function create(Auth $auth, Storage $storage) + { + return new static($auth, $storage); + } +} diff --git a/library/Businessprocess/Web/Component/DashboardAction.php b/library/Businessprocess/Web/Component/DashboardAction.php new file mode 100644 index 0000000..9bd3240 --- /dev/null +++ b/library/Businessprocess/Web/Component/DashboardAction.php @@ -0,0 +1,27 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use Icinga\Web\Url; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Widget\Icon; + +class DashboardAction extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = array('class' => 'action'); + + public function __construct($title, $description, $icon, $url, $urlParams = null, $attributes = null) + { + if (! isset($attributes['href'])) { + $attributes['href'] = Url::fromPath($url, $urlParams ?: []); + } + + $this->add(Html::tag('a', $attributes) + ->add(new Icon($icon)) + ->add(Html::tag('span', ['class' => 'header'], $title)) + ->add($description)); + } +} diff --git a/library/Businessprocess/Web/Component/RenderedProcessActionBar.php b/library/Businessprocess/Web/Component/RenderedProcessActionBar.php new file mode 100644 index 0000000..41fa0f8 --- /dev/null +++ b/library/Businessprocess/Web/Component/RenderedProcessActionBar.php @@ -0,0 +1,161 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use Icinga\Authentication\Auth; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Renderer\Renderer; +use Icinga\Module\Businessprocess\Renderer\TreeRenderer; +use Icinga\Web\Url; +use ipl\Html\Html; +use ipl\Web\Widget\Icon; + +class RenderedProcessActionBar extends ActionBar +{ + public function __construct(BpConfig $config, Renderer $renderer, Url $url) + { + $meta = $config->getMetadata(); + + if ($renderer instanceof TreeRenderer) { + $link = Html::tag( + 'a', + [ + 'href' => $url->with('mode', 'tile'), + 'title' => mt('businessprocess', 'Switch to Tile view') + ] + ); + } else { + $link = Html::tag( + 'a', + [ + 'href' => $url->with('mode', 'tree'), + 'title' => mt('businessprocess', 'Switch to Tree view') + ] + ); + } + + $link->add([ + new Icon('grip', ['class' => $renderer instanceof TreeRenderer ? null : 'active']), + new Icon('sitemap', ['class' => $renderer instanceof TreeRenderer ? 'active' : null]) + ]); + + $this->add( + Html::tag('div', ['class' => 'view-toggle']) + ->add(Html::tag('span', null, mt('businessprocess', 'View'))) + ->add($link) + ); + + $this->add(Html::tag( + 'a', + [ + 'data-base-target' => '_main', + 'href' => $url->with('showFullscreen', true), + 'title' => mt('businessprocess', 'Switch to fullscreen mode'), + ], + [ + new Icon('maximize'), + mt('businessprocess', 'Fullscreen') + ] + )); + + $hasChanges = $config->hasSimulations() || $config->hasBeenChanged(); + + if ($renderer->isLocked()) { + if (! $renderer->wantsRootNodes() && $renderer->rendersImportedNode()) { + $span = Html::tag('span', [ + 'class' => 'disabled', + 'title' => mt( + 'businessprocess', + 'Imported processes can only be changed in their original configuration' + ) + ]); + $span->add([new Icon('lock'), mt('businessprocess', 'Editing Locked')]); + $this->add($span); + } else { + $this->add(Html::tag( + 'a', + [ + 'href' => $url->with('unlocked', true), + 'title' => mt('businessprocess', 'Click to unlock editing for this process'), + ], + [ + new Icon('lock'), + mt('businessprocess', 'Unlock Editing') + ] + )); + } + } elseif (! $hasChanges) { + $this->add(Html::tag( + 'a', + [ + 'href' => $url->without('unlocked')->without('action'), + 'title' => mt('businessprocess', 'Click to lock editing for this process'), + ], + [ + new Icon('lock-open'), + mt('businessprocess', 'Lock Editing') + ] + )); + } + + if (($hasChanges || ! $renderer->isLocked()) && $meta->canModify()) { + if ($renderer->wantsRootNodes()) { + $this->add(Html::tag( + 'a', + [ + 'data-base-target' => '_next', + 'href' => Url::fromPath('businessprocess/process/config', $this->currentProcessParams($url)), + 'title' => mt('businessprocess', 'Modify this process'), + ], + [ + new Icon('wrench'), + mt('businessprocess', 'Config') + ] + )); + } else { + $this->add(Html::tag( + 'a', + [ + 'href' => $url->with([ + 'action' => 'edit', + 'editnode' => $url->getParam('node') + ])->getAbsoluteUrl(), + 'title' => mt('businessprocess', 'Modify this process'), + ], + [ + new Icon('wrench'), + mt('businessprocess', 'Config') + ] + )); + } + } + + if (($hasChanges || (! $renderer->isLocked())) && $meta->canModify()) { + $this->add(Html::tag( + 'a', + [ + 'href' => $url->with('action', 'add'), + 'title' => mt('businessprocess', 'Add a new business process node'), + 'class' => 'button-link' + ], + [ + new Icon('plus'), + mt('businessprocess', 'Add Node') + ] + )); + } + } + + protected function currentProcessParams(Url $url) + { + $urlParams = $url->getParams(); + $params = array(); + foreach (array('config', 'node') as $name) { + if ($value = $urlParams->get($name)) { + $params[$name] = $value; + } + } + + return $params; + } +} diff --git a/library/Businessprocess/Web/Component/Tabs.php b/library/Businessprocess/Web/Component/Tabs.php new file mode 100644 index 0000000..aaa444e --- /dev/null +++ b/library/Businessprocess/Web/Component/Tabs.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use ipl\Html\ValidHtml; + +class Tabs extends WtfTabs implements ValidHtml +{ +} diff --git a/library/Businessprocess/Web/Component/WtfTabs.php b/library/Businessprocess/Web/Component/WtfTabs.php new file mode 100644 index 0000000..8f2250f --- /dev/null +++ b/library/Businessprocess/Web/Component/WtfTabs.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Component; + +use Icinga\Web\Widget\Tabs; + +/** + * Class WtfTabs + * + * TODO: Please remove this as soon as we drop support for PHP 5.3.x + * This works around https://bugs.php.net/bug.php?id=43200 and fixes + * https://github.com/Icinga/icingaweb2-module-businessprocess/issues/81 + * + * @package Icinga\Module\Businessprocess\Web\Component + */ +class WtfTabs extends Tabs +{ + public function render() + { + return parent::render(); + } +} diff --git a/library/Businessprocess/Web/Controller.php b/library/Businessprocess/Web/Controller.php new file mode 100644 index 0000000..43200cc --- /dev/null +++ b/library/Businessprocess/Web/Controller.php @@ -0,0 +1,262 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web; + +use Icinga\Application\Icinga; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Modification\ProcessChanges; +use Icinga\Module\Businessprocess\Storage\LegacyStorage; +use Icinga\Module\Businessprocess\Storage\Storage; +use Icinga\Module\Businessprocess\Web\Component\ActionBar; +use Icinga\Module\Businessprocess\Web\Component\Controls; +use Icinga\Module\Businessprocess\Web\Component\Content; +use Icinga\Module\Businessprocess\Web\Component\Tabs; +use Icinga\Module\Businessprocess\Web\Form\FormLoader; +use Icinga\Web\Notification; +use Icinga\Web\View; +use ipl\Html\Html; +use ipl\Web\Compat\CompatController; + +class Controller extends CompatController +{ + /** @var View */ + public $view; + + /** @var BpConfig */ + protected $bp; + + /** @var Tabs */ + protected $mytabs; + + /** @var Storage */ + private $storage; + + /** @var bool */ + protected $showFullscreen; + + /** @var Url */ + private $url; + + public function init() + { + $m = Icinga::app()->getModuleManager(); + if (! $m->hasLoaded('monitoring') && $m->hasInstalled('monitoring')) { + $m->loadModule('monitoring'); + } + $this->controls(); + $this->content(); + $this->url(); + $this->view->showFullscreen + = $this->showFullscreen + = (bool) $this->_helper->layout()->showFullscreen; + + $this->setViewScript('default'); + } + + /** + * @return Url + */ + protected function url() + { + if ($this->url === null) { + $this->url = Url::fromPath( + $this->getRequest()->getUrl()->getPath() + )->setParams($this->getRequest()->getUrl()->getParams()); + } + + return $this->url; + } + + /** + * @return ActionBar + */ + protected function actions() + { + if ($this->view->actions === null) { + $this->view->actions = new ActionBar(); + } + + return $this->view->actions; + } + + /** + * @return Controls + */ + protected function controls() + { + if ($this->view->controls === null) { + $controls = $this->view->controls = new Controls(); + if ($this->view->compact) { + $controls->getAttributes()->add('class', 'compact'); + } + } + + return $this->view->controls; + } + + /** + * @return Content + */ + protected function content() + { + if ($this->view->content === null) { + $content = $this->view->content = new Content(); + if ($this->view->compact) { + $content->getAttributes()->add('class', 'compact'); + } + } + + return $this->view->content; + } + + /** + * @param $label + * @return Tabs + */ + protected function singleTab($label) + { + return $this->tabs()->add( + 'tab', + array( + 'label' => $label, + 'url' => $this->getRequest()->getUrl() + ) + )->activate('tab'); + } + + /** + * @return Tabs + */ + protected function defaultTab() + { + return $this->singleTab($this->translate('Business Process')); + } + + /** + * @return Tabs + */ + protected function overviewTab() + { + return $this->tabs()->add( + 'overview', + array( + 'label' => $this->translate('Business Process'), + 'url' => 'businessprocess' + ) + )->activate('overview'); + } + + /** + * @return Tabs + */ + protected function tabs() + { + // Todo: do not add to view once all of them render controls() + if ($this->mytabs === null) { + $tabs = new Tabs(); + //$this->controls()->add($tabs); + $this->mytabs = $tabs; + } + + return $this->mytabs; + } + + protected function session() + { + return $this->Window()->getSessionNamespace('businessprocess'); + } + + protected function setViewScript($name) + { + $this->_helper->viewRenderer->setNoController(true); + $this->_helper->viewRenderer->setScriptAction($name); + return $this; + } + + protected function addTitle($title) + { + $args = func_get_args(); + array_shift($args); + $this->view->title = vsprintf($title, $args); + $this->controls()->add(Html::tag('h1', null, $this->view->title)); + return $this; + } + + protected function loadModifiedBpConfig() + { + $bp = $this->loadBpConfig(); + $changes = ProcessChanges::construct($bp, $this->session()); + if ($this->params->get('dismissChanges')) { + Notification::success( + sprintf( + $this->translate('%d pending change(s) have been dropped'), + $changes->count() + ) + ); + $changes->clear(); + $this->redirectNow($this->url()->without('dismissChanges')->without('unlocked')); + } + $bp->applyChanges($changes); + return $bp; + } + + protected function doNotRender() + { + $this->_helper->layout()->disableLayout(); + $this->_helper->viewRenderer->setNoRender(true); + return $this; + } + + protected function loadBpConfig() + { + $name = $this->params->get('config'); + /** @var LegacyStorage $storage */ + $storage = $this->storage(); + + if (! $storage->hasProcess($name)) { + $this->httpNotFound( + $this->translate('No such process config: "%s"'), + $name + ); + } + + $modifications = $this->session()->get('modifications', array()); + if (array_key_exists($name, $modifications)) { + $bp = $storage->loadFromString($name, $modifications[$name]); + } else { + $bp = $storage->loadProcess($name); + } + + // allow URL parameter to override configured state type + if (null !== ($stateType = $this->params->get('state_type'))) { + if ($stateType === 'soft') { + $bp->useSoftStates(); + } + if ($stateType === 'hard') { + $bp->useHardStates(); + } + } + + $this->view->bpconfig = $this->bp = $bp; + $this->view->configName = $bp->getName(); + + return $bp; + } + + public function loadForm($name) + { + return FormLoader::load($name, $this->Module()); + } + + /** + * @return LegacyStorage + */ + protected function storage() + { + if ($this->storage === null) { + $this->storage = LegacyStorage::getInstance(); + } + + return $this->storage; + } +} diff --git a/library/Businessprocess/Web/FakeRequest.php b/library/Businessprocess/Web/FakeRequest.php new file mode 100644 index 0000000..4e54117 --- /dev/null +++ b/library/Businessprocess/Web/FakeRequest.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web; + +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Request; + +class FakeRequest extends Request +{ + /** @var string */ + private static $baseUrl; + + public static function setConfiguredBaseUrl($url) + { + self::$baseUrl = $url; + } + + public function getBaseUrl($raw = false) + { + if (self::$baseUrl === null) { + throw new ProgrammingError('Cannot determine base URL on CLI if not configured'); + } else { + return self::$baseUrl; + } + } +} diff --git a/library/Businessprocess/Web/Form/BpConfigBaseForm.php b/library/Businessprocess/Web/Form/BpConfigBaseForm.php new file mode 100644 index 0000000..5ccdf06 --- /dev/null +++ b/library/Businessprocess/Web/Form/BpConfigBaseForm.php @@ -0,0 +1,135 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form; + +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\Storage\Storage; +use Icinga\Module\Monitoring\Backend\MonitoringBackend; +use Icinga\Web\Session\SessionNamespace; +use ipl\Sql\Connection as IcingaDbConnection; + +abstract class BpConfigBaseForm extends QuickForm +{ + /** @var Storage */ + protected $storage; + + /** @var BpConfig */ + protected $bp; + + /** @var MonitoringBackend|IcingaDbConnection*/ + protected $backend; + + /** @var SessionNamespace */ + protected $session; + + protected function listAvailableBackends() + { + $keys = []; + $moduleManager = Icinga::app()->getModuleManager(); + if ($moduleManager->hasEnabled('monitoring')) { + $keys = array_keys(Config::module('monitoring', 'backends')->toArray()); + $keys = array_combine($keys, $keys); + } + + return $keys; + } + + /** + * Set the storage to use + * + * @param Storage $storage + * + * @return $this + */ + public function setStorage(Storage $storage): self + { + $this->storage = $storage; + + return $this; + } + + /** + * Set the config to use + * + * @param BpConfig $config + * + * @return $this + */ + public function setProcess(BpConfig $config): self + { + $this->bp = $config; + $this->setBackend($config->getBackend()); + + return $this; + } + + /** + * Set the backend to use + * + * @param MonitoringBackend|IcingaDbConnection $backend + * + * @return $this + */ + public function setBackend($backend): self + { + $this->backend = $backend; + + return $this; + } + + /** + * Set the session namespace to use + * + * @param SessionNamespace $session + * + * @return $this + */ + public function setSession(SessionNamespace $session): self + { + $this->session = $session; + + return $this; + } + + protected function prepareMetadata(BpConfig $config) + { + $meta = $config->getMetadata(); + $auth = Auth::getInstance(); + $meta->set('Owner', $auth->getUser()->getUsername()); + + if ($auth->hasPermission('businessprocess/showall')) { + return true; + } + + $prefixes = $auth->getRestrictions('businessprocess/prefix'); + if (! empty($prefixes) && ! $meta->nameIsPrefixedWithOneOf($prefixes)) { + if (count($prefixes) === 1) { + $this->getElement('name')->addError(sprintf( + $this->translate('Please prefix the name with "%s"'), + current($prefixes) + )); + } else { + $this->getElement('name')->addError(sprintf( + $this->translate('Please prefix the name with one of "%s"'), + implode('", "', $prefixes) + )); + } + + return false; + } + + return true; + } + + protected function setPreferredDecorators() + { + parent::setPreferredDecorators(); + + $this->setAttrib('class', $this->getAttrib('class') . ' bp-form'); + + return $this; + } +} diff --git a/library/Businessprocess/Web/Form/CsrfToken.php b/library/Businessprocess/Web/Form/CsrfToken.php new file mode 100644 index 0000000..9eb24ef --- /dev/null +++ b/library/Businessprocess/Web/Form/CsrfToken.php @@ -0,0 +1,53 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form; + +class CsrfToken +{ + /** + * Check whether the given token is valid + * + * @param string $token Token + * + * @return bool + */ + public static function isValid($token) + { + if (strpos($token, '|') === false) { + return false; + } + + list($seed, $token) = explode('|', $token); + + if (!is_numeric($seed)) { + return false; + } + + return $token === hash('sha256', self::getSessionId() . $seed); + } + + /** + * Create a new token + * + * @return string + */ + public static function generate() + { + $seed = mt_rand(); + $token = hash('sha256', self::getSessionId() . $seed); + + return sprintf('%s|%s', $seed, $token); + } + + /** + * Get current session id + * + * TODO: we should do this through our App or Session object + * + * @return string + */ + protected static function getSessionId() + { + return session_id(); + } +} diff --git a/library/Businessprocess/Web/Form/Element/Checkbox.php b/library/Businessprocess/Web/Form/Element/Checkbox.php new file mode 100644 index 0000000..7975b82 --- /dev/null +++ b/library/Businessprocess/Web/Form/Element/Checkbox.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form\Element; + +class Checkbox extends \Icinga\Web\Form\Element\Checkbox +{ + +} diff --git a/library/Businessprocess/Web/Form/Element/FormElement.php b/library/Businessprocess/Web/Form/Element/FormElement.php new file mode 100644 index 0000000..7647a5e --- /dev/null +++ b/library/Businessprocess/Web/Form/Element/FormElement.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form\Element; + +use Zend_Form_Element_Xhtml; + +class FormElement extends Zend_Form_Element_Xhtml +{ +} diff --git a/library/Businessprocess/Web/Form/Element/IplStateOverrides.php b/library/Businessprocess/Web/Form/Element/IplStateOverrides.php new file mode 100644 index 0000000..5b9ea16 --- /dev/null +++ b/library/Businessprocess/Web/Form/Element/IplStateOverrides.php @@ -0,0 +1,75 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form\Element; + +use ipl\Html\Attributes; +use ipl\Html\FormElement\FieldsetElement; + +class IplStateOverrides extends FieldsetElement +{ + /** @var array */ + protected $options = []; + + /** + * Set the options show + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options): self + { + $this->options = $options; + + return $this; + } + + /** + * Get the options to show + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + public function getValues() + { + $cleanedValue = parent::getValues(); + + if (! empty($cleanedValue)) { + foreach ($cleanedValue as $from => $to) { + if ((int) $from === (int) $to) { + unset($cleanedValue[$from]); + } + } + } + + return $cleanedValue; + } + + protected function assemble() + { + $states = $this->getOptions(); + foreach ($states as $state => $label) { + if ($state === 0) { + continue; + } + + $this->addElement('select', $state, [ + 'label' => $label, + 'value' => $state, + 'options' => [$state => $this->translate('Keep actual state')] + $states + ]); + } + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $this->getAttributes() + ->registerAttributeCallback('options', null, [$this, 'setOptions']); + } +} diff --git a/library/Businessprocess/Web/Form/Element/SimpleNote.php b/library/Businessprocess/Web/Form/Element/SimpleNote.php new file mode 100644 index 0000000..9f757f2 --- /dev/null +++ b/library/Businessprocess/Web/Form/Element/SimpleNote.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form\Element; + +class SimpleNote extends FormElement +{ + public $helper = 'formSimpleNote'; + + /** + * Always ignore this element + * @codingStandardsIgnoreStart + * + * @var boolean + */ + protected $_ignore = true; + // @codingStandardsIgnoreEnd + + public function isValid($value, $context = null) + { + return true; + } +} diff --git a/library/Businessprocess/Web/Form/FormLoader.php b/library/Businessprocess/Web/Form/FormLoader.php new file mode 100644 index 0000000..0cc5389 --- /dev/null +++ b/library/Businessprocess/Web/Form/FormLoader.php @@ -0,0 +1,39 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use Icinga\Exception\ProgrammingError; + +class FormLoader +{ + public static function load($name, Module $module = null) + { + if ($module === null) { + $basedir = Icinga::app()->getApplicationDir('forms'); + $ns = '\\Icinga\\Web\\Forms\\'; + } else { + $basedir = $module->getFormDir(); + $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Forms\\'; + } + + $file = null; + if (preg_match('~^[a-z0-9/]+$~i', $name)) { + $parts = preg_split('~/~', $name); + $class = ucfirst(array_pop($parts)) . 'Form'; + $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class); + if (file_exists($file)) { + require_once($file); + $class = $ns . $class; + $options = array(); + if ($module !== null) { + $options['icingaModule'] = $module; + } + + return new $class($options); + } + } + throw new ProgrammingError(sprintf('Cannot load %s (%s), no such form', $name, $file)); + } +} diff --git a/library/Businessprocess/Web/Form/QuickBaseForm.php b/library/Businessprocess/Web/Form/QuickBaseForm.php new file mode 100644 index 0000000..36d134f --- /dev/null +++ b/library/Businessprocess/Web/Form/QuickBaseForm.php @@ -0,0 +1,166 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use ipl\Html\ValidHtml; +use Zend_Form; + +abstract class QuickBaseForm extends Zend_Form implements ValidHtml +{ + /** + * The Icinga module this form belongs to. Usually only set if the + * form is initialized through the FormLoader + * + * @var ?Module + */ + protected $icingaModule; + + protected $icingaModuleName; + + private $hintCount = 0; + + public function __construct($options = null) + { + $this->callZfConstructor($this->handleOptions($options)) + ->initializePrefixPaths(); + } + + protected function callZfConstructor($options = null) + { + parent::__construct($options); + return $this; + } + + protected function initializePrefixPaths() + { + $this->addPrefixPathsForBusinessprocess(); + if ($this->icingaModule && $this->icingaModuleName !== 'businessprocess') { + $this->addPrefixPathsForModule($this->icingaModule); + } + } + + protected function addPrefixPathsForBusinessprocess() + { + $module = Icinga::app() + ->getModuleManager() + ->loadModule('businessprocess') + ->getModule('businessprocess'); + + $this->addPrefixPathsForModule($module); + } + + public function addPrefixPathsForModule(Module $module) + { + $basedir = sprintf( + '%s/%s/Web/Form', + $module->getLibDir(), + ucfirst($module->getName()) + ); + + $this->addPrefixPaths(array( + array( + 'prefix' => __NAMESPACE__ . '\\Element\\', + 'path' => $basedir . '/Element', + 'type' => static::ELEMENT + ) + )); + + return $this; + } + + public function addHidden($name, $value = null) + { + $this->addElement('hidden', $name); + $el = $this->getElement($name); + $el->setDecorators(array('ViewHelper')); + if ($value !== null) { + $this->setDefault($name, $value); + $el->setValue($value); + } + + return $this; + } + + // TODO: Should be an element + public function addHtmlHint($html, $options = array()) + { + return $this->addHtml('<div class="hint">' . $html . '</div>', $options); + } + + public function addHtml($html, $options = array()) + { + if (array_key_exists('name', $options)) { + $name = $options['name']; + unset($options['name']); + } else { + $name = '_HINT' . ++$this->hintCount; + } + + $this->addElement('simpleNote', $name, $options); + $this->getElement($name) + ->setValue($html) + ->setIgnore(true) + ->setDecorators(array('ViewHelper')); + + return $this; + } + + public function optionalEnum($enum, $nullLabel = null) + { + if ($nullLabel === null) { + $nullLabel = $this->translate('- please choose -'); + } + + return array(null => $nullLabel) + $enum; + } + + protected function handleOptions($options = null) + { + if ($options === null) { + return $options; + } + + if (array_key_exists('icingaModule', $options)) { + $this->icingaModule = $options['icingaModule']; + $this->icingaModuleName = $this->icingaModule->getName(); + unset($options['icingaModule']); + } + + return $options; + } + + public function setIcingaModule(Module $module) + { + $this->icingaModule = $module; + return $this; + } + + protected function loadForm($name, Module $module = null) + { + if ($module === null) { + $module = $this->icingaModule; + } + + return FormLoader::load($name, $module); + } + + protected function valueIsEmpty($value) + { + if (is_array($value)) { + return empty($value); + } + + return strlen($value) === 0; + } + + public function translate($string) + { + if ($this->icingaModuleName === null) { + return t($string); + } else { + return mt($this->icingaModuleName, $string); + } + } +} diff --git a/library/Businessprocess/Web/Form/QuickForm.php b/library/Businessprocess/Web/Form/QuickForm.php new file mode 100644 index 0000000..cb4d287 --- /dev/null +++ b/library/Businessprocess/Web/Form/QuickForm.php @@ -0,0 +1,514 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Application\Web; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Notification; +use Icinga\Web\Request; +use Icinga\Web\Response; +use Icinga\Web\Url; +use Exception; + +/** + * QuickForm wants to be a base class for simple forms + */ +abstract class QuickForm extends QuickBaseForm +{ + const ID = '__FORM_NAME'; + + const CSRF = '__FORM_CSRF'; + + /** + * The name of this form + */ + protected $formName; + + /** + * Whether the form has been sent + */ + protected $hasBeenSent; + + /** + * Whether the form has been sent + */ + protected $hasBeenSubmitted; + + /** + * The submit caption, element - still tbd + */ + // protected $submit; + + /** + * Our request + */ + protected $request; + + /** + * @var ?Url + */ + protected $successUrl; + + protected $successMessage; + + protected $submitLabel; + + protected $submitButtonName; + + protected $deleteButtonName; + + protected $fakeSubmitButtonName; + + /** + * Whether form elements have already been created + */ + protected $didSetup = false; + + protected $isApiRequest = false; + + public function __construct($options = null) + { + parent::__construct($options); + + $this->setMethod('post'); + $this->getActionFromRequest() + ->createIdElement() + ->regenerateCsrfToken() + ->setPreferredDecorators(); + } + + protected function getActionFromRequest() + { + $this->setAction(Url::fromRequest()); + return $this; + } + + protected function setPreferredDecorators() + { + $this->setAttrib('class', 'autofocus icinga-controls'); + $this->setDecorators( + array( + 'Description', + array('FormErrors', array('onlyCustomFormErrors' => true)), + 'FormElements', + 'Form' + ) + ); + + return $this; + } + + protected function addSubmitButtonIfSet() + { + if (false === ($label = $this->getSubmitLabel())) { + return; + } + + if ($this->submitButtonName && $el = $this->getElement($this->submitButtonName)) { + return; + } + + $el = $this->createElement('submit', $label) + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + $this->submitButtonName = $el->getName(); + $this->addElement($el); + + $fakeEl = $this->createElement('submit', '_FAKE_SUBMIT') + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + $this->fakeSubmitButtonName = $fakeEl->getName(); + $this->addElement($fakeEl); + + $this->addDisplayGroup( + array($this->fakeSubmitButtonName), + 'fake_button', + array( + 'decorators' => array('FormElements'), + 'order' => 1, + ) + ); + + $grp = array( + $this->submitButtonName, + $this->deleteButtonName + ); + $this->addDisplayGroup($grp, 'buttons', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'DtDdWrapper', + ), + 'order' => 1000, + )); + } + + protected function addSimpleDisplayGroup($elements, $name, $options) + { + if (! array_key_exists('decorators', $options)) { + $options['decorators'] = array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ); + } + + return $this->addDisplayGroup($elements, $name, $options); + } + + protected function createIdElement() + { + $this->detectName(); + $this->addHidden(self::ID, $this->getName()); + $this->getElement(self::ID)->setIgnore(true); + return $this; + } + + public function getSentValue($name, $default = null) + { + $request = $this->getRequest(); + if ($request->isPost() && $this->hasBeenSent()) { + return $request->getPost($name); + } else { + return $default; + } + } + + public function getSubmitLabel() + { + if ($this->submitLabel === null) { + return $this->translate('Submit'); + } + + return $this->submitLabel; + } + + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + return $this; + } + + public function setApiRequest($isApiRequest = true) + { + $this->isApiRequest = $isApiRequest; + return $this; + } + + public function isApiRequest() + { + return $this->isApiRequest; + } + + public function regenerateCsrfToken() + { + if (! $element = $this->getElement(self::CSRF)) { + $this->addHidden(self::CSRF, CsrfToken::generate()); + $element = $this->getElement(self::CSRF); + } + $element->setIgnore(true); + + return $this; + } + + public function removeCsrfToken() + { + $this->removeElement(self::CSRF); + return $this; + } + + public function setSuccessUrl($url, $params = null) + { + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + if ($params !== null) { + $url->setParams($params); + } + $this->successUrl = $url; + return $this; + } + + public function getSuccessUrl() + { + $url = $this->successUrl ?: $this->getAction(); + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + + return $url; + } + + protected function beforeSetup() + { + } + + public function setup() + { + } + + protected function onSetup() + { + } + + /** + * @param $action string|Url + * @return $this + */ + public function setAction($action) + { + if ($action instanceof Url) { + $action = $action->getAbsoluteUrl('&'); + } + + return parent::setAction($action); + } + + public function hasBeenSubmitted() + { + if ($this->hasBeenSubmitted === null) { + $req = $this->getRequest(); + if ($req->isPost()) { + if (! $this->hasSubmitButton()) { + return $this->hasBeenSubmitted = $this->hasBeenSent(); + } + + $this->hasBeenSubmitted = $this->pressedButton( + $this->fakeSubmitButtonName, + $this->getSubmitLabel() + ) || $this->pressedButton( + $this->submitButtonName, + $this->getSubmitLabel() + ); + } else { + $this->hasBeenSubmitted = false; + } + } + + return $this->hasBeenSubmitted; + } + + protected function hasSubmitButton() + { + return $this->submitButtonName !== null; + } + + protected function pressedButton($name, $label) + { + $req = $this->getRequest(); + if (! $req->isPost()) { + return false; + } + + $req = $this->getRequest(); + $post = $req->getPost(); + + return array_key_exists($name, $post) + && $post[$name] === $label; + } + + protected function beforeValidation($data = array()) + { + } + + public function prepareElements() + { + if (! $this->didSetup) { + $this->beforeSetup(); + $this->setup(); + $this->addSubmitButtonIfSet(); + $this->onSetup(); + $this->didSetup = true; + } + + return $this; + } + + public function handleRequest(Request $request = null) + { + if ($request === null) { + $request = $this->getRequest(); + } else { + $this->setRequest($request); + } + + $this->prepareElements(); + + if ($this->hasBeenSent()) { + $post = $request->getPost(); + if ($this->hasBeenSubmitted()) { + $this->beforeValidation($post); + if ($this->isValid($post)) { + try { + $this->onSuccess(); + } catch (Exception $e) { + $this->addException($e); + $this->onFailure(); + } + } else { + $this->onFailure(); + } + } else { + $this->setDefaults($post); + } + } else { + // Well... + } + + return $this; + } + + public function addException(Exception $e, $elementName = null) + { + $file = preg_split('/[\/\\\]/', $e->getFile(), -1, PREG_SPLIT_NO_EMPTY); + $file = array_pop($file); + $msg = sprintf( + '%s (%s:%d)', + $e->getMessage(), + $file, + $e->getLine() + ); + + if ($el = $this->getElement($elementName)) { + $el->addError($msg); + } else { + $this->addError($msg); + } + } + + public function onSuccess() + { + $this->redirectOnSuccess(); + } + + public function setSuccessMessage($message) + { + $this->successMessage = $message; + return $this; + } + + public function getSuccessMessage($message = null) + { + if ($message !== null) { + return $message; + } + if ($this->successMessage === null) { + return t('Form has successfully been sent'); + } + return $this->successMessage; + } + + public function redirectOnSuccess($message = null) + { + if ($this->isApiRequest()) { + // TODO: Set the status line message? + $this->successMessage = $this->getSuccessMessage($message); + return; + } + + $url = $this->getSuccessUrl(); + $this->notifySuccess($this->getSuccessMessage($message)); + $this->redirectAndExit($url); + } + + public function onFailure() + { + } + + public function notifySuccess($message = null) + { + if ($message === null) { + $message = t('Form has successfully been sent'); + } + Notification::success($message); + return $this; + } + + public function notifyError($message) + { + Notification::error($message); + return $this; + } + + protected function redirectAndExit($url) + { + /** @var Web $app */ + $app = Icinga::app(); + /** @var Response $response */ + $response = $app->getFrontController()->getResponse(); + $response->redirectAndExit($url); + } + + protected function setHttpResponseCode($code) + { + /** @var Web $app */ + $app = Icinga::app(); + $app->getFrontController()->getResponse()->setHttpResponseCode($code); + return $this; + } + + protected function onRequest() + { + } + + public function setRequest(Request $request) + { + if ($this->request !== null) { + throw new ProgrammingError('Unable to set request twice'); + } + + $this->request = $request; + $this->prepareElements(); + $this->onRequest(); + return $this; + } + + /** + * @return Request + */ + public function getRequest() + { + if ($this->request === null) { + /** @var Web $app */ + $app = Icinga::app(); + /** @var Request $request */ + $request = $app->getFrontController()->getRequest(); + $this->setRequest($request); + } + return $this->request; + } + + public function hasBeenSent() + { + if ($this->hasBeenSent === null) { + if ($this->request === null) { + /** @var Web $app */ + $app = Icinga::app(); + $req = $app->getFrontController()->getRequest(); + } else { + $req = $this->request; + } + + /** @var Request $req */ + if ($req->isPost()) { + $post = $req->getPost(); + $this->hasBeenSent = array_key_exists(self::ID, $post) && + $post[self::ID] === $this->getName(); + } else { + $this->hasBeenSent = false; + } + } + + return $this->hasBeenSent; + } + + protected function detectName() + { + if ($this->formName !== null) { + $this->setName($this->formName); + } else { + $this->setName(get_class($this)); + } + } +} diff --git a/library/Businessprocess/Web/Form/Validator/HostServiceTermValidator.php b/library/Businessprocess/Web/Form/Validator/HostServiceTermValidator.php new file mode 100644 index 0000000..58249f7 --- /dev/null +++ b/library/Businessprocess/Web/Form/Validator/HostServiceTermValidator.php @@ -0,0 +1,96 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Form\Validator; + +use Icinga\Module\Businessprocess\BpConfig; +use Icinga\Module\Businessprocess\BpNode; +use Icinga\Module\Businessprocess\ServiceNode; +use Icinga\Module\Businessprocess\State\IcingaDbState; +use Icinga\Module\Businessprocess\State\MonitoringState; +use Icinga\Module\Monitoring\Backend\MonitoringBackend; +use ipl\I18n\Translation; +use ipl\Validator\BaseValidator; +use ipl\Web\FormElement\TermInput\Term; +use LogicException; + +class HostServiceTermValidator extends BaseValidator +{ + use Translation; + + /** @var ?BpNode */ + protected $parent; + + /** + * Set the affected process + * + * @param BpNode $parent + * + * @return $this + */ + public function setParent(BpNode $parent): self + { + $this->parent = $parent; + + return $this; + } + + public function isValid($terms) + { + if ($this->parent === null) { + throw new LogicException('Missing parent process. Cannot validate terms.'); + } + + if (! is_array($terms)) { + $terms = [$terms]; + } + + $isValid = true; + $testConfig = new BpConfig(); + + foreach ($terms as $term) { + /** @var Term $term */ + [$hostName, $serviceName] = BpConfig::splitNodeName($term->getSearchValue()); + if ($serviceName !== null && $serviceName !== 'Hoststatus') { + $node = $testConfig->createService($hostName, $serviceName); + } else { + $node = $testConfig->createHost($hostName); + if ($serviceName === null) { + $term->setSearchValue(BpConfig::joinNodeName($hostName, 'Hoststatus')); + } + } + + if ($this->parent->hasChild($term->getSearchValue())) { + $term->setMessage($this->translate('Already defined in this process')); + $isValid = false; + } else { + $testConfig->getNode('__unbound__') + ->addChild($node); + } + } + + if ($this->parent->getBpConfig()->getBackend() instanceof MonitoringBackend) { + MonitoringState::apply($testConfig); + } else { + IcingaDbState::apply($testConfig); + } + + foreach ($terms as $term) { + /** @var Term $term */ + $node = $testConfig->getNode($term->getSearchValue()); + if ($node->isMissing()) { + if ($node instanceof ServiceNode) { + $term->setMessage($this->translate('Service not found')); + } else { + $term->setMessage($this->translate('Host not found')); + } + + $isValid = false; + } else { + $term->setLabel($node->getAlias()); + $term->setClass($node->getObjectClassName()); + } + } + + return $isValid; + } +} diff --git a/library/Businessprocess/Web/Navigation/Renderer/ProcessProblemsBadge.php b/library/Businessprocess/Web/Navigation/Renderer/ProcessProblemsBadge.php new file mode 100644 index 0000000..575dc5e --- /dev/null +++ b/library/Businessprocess/Web/Navigation/Renderer/ProcessProblemsBadge.php @@ -0,0 +1,59 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Navigation\Renderer; + +use Icinga\Module\Businessprocess\Storage\LegacyStorage; +use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer; + +class ProcessProblemsBadge extends BadgeNavigationItemRenderer +{ + /** + * Cached count + * + * @var int + */ + protected $count; + + /** @var string */ + private $bpConfigName; + + public function getCount() + { + $count = 0; + if ($this->count === null) { + $storage = LegacyStorage::getInstance(); + $bp = $storage->loadProcess($this->getBpConfigName()); + foreach ($bp->getRootNodes() as $rootNode) { + if (! $rootNode->isEmpty() && + $rootNode->getState() !== $rootNode::ICINGA_PENDING + && $rootNode->hasProblems()) { + $count++; + } + } + + $this->count = $count; + $this->setState(self::STATE_CRITICAL); + } + + if ($count) { + $this->setTitle(sprintf( + tp('One unhandled root node critical', '%d unhandled root nodes critical', $count), + $count + )); + } + + return $this->count; + } + + public function setBpConfigName($bpConfigName) + { + $this->bpConfigName = $bpConfigName; + + return $this; + } + + public function getBpConfigName() + { + return $this->bpConfigName; + } +} diff --git a/library/Businessprocess/Web/Navigation/Renderer/ProcessesProblemsBadge.php b/library/Businessprocess/Web/Navigation/Renderer/ProcessesProblemsBadge.php new file mode 100644 index 0000000..dd419a2 --- /dev/null +++ b/library/Businessprocess/Web/Navigation/Renderer/ProcessesProblemsBadge.php @@ -0,0 +1,53 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web\Navigation\Renderer; + +use Icinga\Application\Modules\Module; +use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport; +use Icinga\Module\Businessprocess\State\IcingaDbState; +use Icinga\Module\Businessprocess\State\MonitoringState; +use Icinga\Module\Businessprocess\Storage\LegacyStorage; +use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer; + +class ProcessesProblemsBadge extends BadgeNavigationItemRenderer +{ + /** + * Cached count + * + * @var int + */ + protected $count; + + public function getCount() + { + if ($this->count === null) { + $storage = LegacyStorage::getInstance(); + $count = 0; + + foreach ($storage->listProcessNames() as $processName) { + $bp = $storage->loadProcess($processName); + if (Module::exists('icingadb') && + (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend()) + ) { + IcingaDbState::apply($bp); + } else { + MonitoringState::apply($bp); + } + + foreach ($bp->getRootNodes() as $rootNode) { + if (! $rootNode->isEmpty() && + $rootNode->getState() !== $rootNode::ICINGA_PENDING + && $rootNode->hasProblems()) { + $count++; + break; + } + } + } + + $this->count = $count; + $this->setState(self::STATE_CRITICAL); + } + + return $this->count; + } +} diff --git a/library/Businessprocess/Web/Url.php b/library/Businessprocess/Web/Url.php new file mode 100644 index 0000000..92b1e85 --- /dev/null +++ b/library/Businessprocess/Web/Url.php @@ -0,0 +1,32 @@ +<?php + +namespace Icinga\Module\Businessprocess\Web; + +use Icinga\Application\Icinga; +use Icinga\Application\Web; +use Icinga\Web\Request; +use Icinga\Web\Url as WebUrl; + +/** + * Class Url + * + * The main purpose of this class is to get unit tests running on CLI + * + * @package Icinga\Module\Businessprocess\Web + */ +class Url extends WebUrl +{ + /** + * @return FakeRequest|Request + */ + protected static function getRequest() + { + $app = Icinga::app(); + if ($app->isCli()) { + return new FakeRequest(); + } + + /** @var Web $app */ + return $app->getRequest(); + } +} |