summaryrefslogtreecommitdiffstats
path: root/library/Businessprocess
diff options
context:
space:
mode:
Diffstat (limited to 'library/Businessprocess')
-rw-r--r--library/Businessprocess/BpConfig.php1117
-rw-r--r--library/Businessprocess/BpNode.php646
-rw-r--r--library/Businessprocess/Common/Sort.php158
-rw-r--r--library/Businessprocess/Director/ShipConfigFiles.php22
-rw-r--r--library/Businessprocess/Exception/ModificationError.php9
-rw-r--r--library/Businessprocess/Exception/NestingError.php9
-rw-r--r--library/Businessprocess/HostNode.php64
-rw-r--r--library/Businessprocess/IcingaDbObject.php94
-rw-r--r--library/Businessprocess/ImportedNode.php139
-rw-r--r--library/Businessprocess/Metadata.php264
-rw-r--r--library/Businessprocess/Modification/NodeAction.php179
-rw-r--r--library/Businessprocess/Modification/NodeAddChildrenAction.php74
-rw-r--r--library/Businessprocess/Modification/NodeApplyManualOrderAction.php35
-rw-r--r--library/Businessprocess/Modification/NodeCopyAction.php48
-rw-r--r--library/Businessprocess/Modification/NodeCreateAction.php129
-rw-r--r--library/Businessprocess/Modification/NodeModifyAction.php121
-rw-r--r--library/Businessprocess/Modification/NodeMoveAction.php227
-rw-r--r--library/Businessprocess/Modification/NodeRemoveAction.php125
-rw-r--r--library/Businessprocess/Modification/ProcessChanges.php294
-rw-r--r--library/Businessprocess/MonitoredNode.php19
-rw-r--r--library/Businessprocess/Monitoring/Backend/Ido/Query/CustomVarJoinTemplateOverride.php84
-rw-r--r--library/Businessprocess/Monitoring/Backend/Ido/Query/HostStatusQuery.php8
-rw-r--r--library/Businessprocess/Monitoring/Backend/Ido/Query/ServiceStatusQuery.php8
-rw-r--r--library/Businessprocess/Monitoring/DataView/HostStatus.php16
-rw-r--r--library/Businessprocess/Monitoring/DataView/ServiceStatus.php16
-rw-r--r--library/Businessprocess/MonitoringRestrictions.php65
-rw-r--r--library/Businessprocess/Node.php570
-rw-r--r--library/Businessprocess/ProvidedHook/Icingadb/HostActions.php23
-rw-r--r--library/Businessprocess/ProvidedHook/Icingadb/IcingadbSupport.php10
-rw-r--r--library/Businessprocess/ProvidedHook/Icingadb/ServiceActions.php25
-rw-r--r--library/Businessprocess/ProvidedHook/Icingadb/ServiceDetailExtension.php77
-rw-r--r--library/Businessprocess/ProvidedHook/Monitoring/DetailviewExtension.php83
-rw-r--r--library/Businessprocess/ProvidedHook/Monitoring/HostActions.php19
-rw-r--r--library/Businessprocess/ProvidedHook/Monitoring/ServiceActions.php24
-rw-r--r--library/Businessprocess/Renderer/Breadcrumb.php80
-rw-r--r--library/Businessprocess/Renderer/Renderer.php431
-rw-r--r--library/Businessprocess/Renderer/TileRenderer.php85
-rw-r--r--library/Businessprocess/Renderer/TileRenderer/NodeTile.php353
-rw-r--r--library/Businessprocess/Renderer/TreeRenderer.php380
-rw-r--r--library/Businessprocess/ServiceNode.php95
-rw-r--r--library/Businessprocess/Simulation.php185
-rw-r--r--library/Businessprocess/State/IcingaDbState.php191
-rw-r--r--library/Businessprocess/State/MonitoringState.php151
-rw-r--r--library/Businessprocess/Storage/ConfigDiff.php77
-rw-r--r--library/Businessprocess/Storage/LegacyConfigParser.php413
-rw-r--r--library/Businessprocess/Storage/LegacyConfigRenderer.php268
-rw-r--r--library/Businessprocess/Storage/LegacyStorage.php205
-rw-r--r--library/Businessprocess/Storage/Storage.php107
-rw-r--r--library/Businessprocess/Test/BaseTestCase.php76
-rw-r--r--library/Businessprocess/Test/Bootstrap.php29
-rw-r--r--library/Businessprocess/Web/Component/ActionBar.php15
-rw-r--r--library/Businessprocess/Web/Component/BpDashboardTile.php47
-rw-r--r--library/Businessprocess/Web/Component/Content.php14
-rw-r--r--library/Businessprocess/Web/Component/Controls.php14
-rw-r--r--library/Businessprocess/Web/Component/Dashboard.php140
-rw-r--r--library/Businessprocess/Web/Component/DashboardAction.php27
-rw-r--r--library/Businessprocess/Web/Component/RenderedProcessActionBar.php161
-rw-r--r--library/Businessprocess/Web/Component/Tabs.php9
-rw-r--r--library/Businessprocess/Web/Component/WtfTabs.php22
-rw-r--r--library/Businessprocess/Web/Controller.php262
-rw-r--r--library/Businessprocess/Web/FakeRequest.php26
-rw-r--r--library/Businessprocess/Web/Form/BpConfigBaseForm.php135
-rw-r--r--library/Businessprocess/Web/Form/CsrfToken.php53
-rw-r--r--library/Businessprocess/Web/Form/Element/Checkbox.php8
-rw-r--r--library/Businessprocess/Web/Form/Element/FormElement.php9
-rw-r--r--library/Businessprocess/Web/Form/Element/IplStateOverrides.php75
-rw-r--r--library/Businessprocess/Web/Form/Element/SimpleNote.php22
-rw-r--r--library/Businessprocess/Web/Form/FormLoader.php39
-rw-r--r--library/Businessprocess/Web/Form/QuickBaseForm.php166
-rw-r--r--library/Businessprocess/Web/Form/QuickForm.php514
-rw-r--r--library/Businessprocess/Web/Form/Validator/HostServiceTermValidator.php96
-rw-r--r--library/Businessprocess/Web/Navigation/Renderer/ProcessProblemsBadge.php59
-rw-r--r--library/Businessprocess/Web/Navigation/Renderer/ProcessesProblemsBadge.php53
-rw-r--r--library/Businessprocess/Web/Url.php32
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 '&nbsp;';
+ }
+
+ 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('&nbsp;')->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();
+ }
+}