summaryrefslogtreecommitdiffstats
path: root/application
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-14 13:15:40 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-14 13:15:40 +0000
commitb7fd908d538ed19fe41f03c0a3f93351d8da64e9 (patch)
tree46e14f318948cd4f5d7e874f83e7dfcc5d42fc64 /application
parentInitial commit. (diff)
downloadicingaweb2-module-businessprocess-upstream.tar.xz
icingaweb2-module-businessprocess-upstream.zip
Adding upstream version 2.5.0.upstream/2.5.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--application/clicommands/CheckCommand.php23
-rw-r--r--application/clicommands/CleanupCommand.php106
-rw-r--r--application/clicommands/ProcessCommand.php227
-rw-r--r--application/controllers/HostController.php66
-rw-r--r--application/controllers/IndexController.php20
-rw-r--r--application/controllers/NodeController.php148
-rw-r--r--application/controllers/ProcessController.php780
-rw-r--r--application/controllers/ServiceController.php74
-rw-r--r--application/controllers/SuggestionsController.php372
-rw-r--r--application/forms/AddNodeForm.php412
-rw-r--r--application/forms/BpConfigForm.php236
-rw-r--r--application/forms/BpUploadForm.php207
-rw-r--r--application/forms/CleanupNodeForm.php61
-rw-r--r--application/forms/DeleteNodeForm.php125
-rw-r--r--application/forms/EditNodeForm.php315
-rw-r--r--application/forms/MoveNodeForm.php172
-rw-r--r--application/forms/ProcessForm.php158
-rw-r--r--application/forms/SimulationForm.php138
-rw-r--r--application/views/helpers/FormSimpleNote.php15
-rw-r--r--application/views/helpers/RenderStateBadges.php33
-rw-r--r--application/views/scripts/default.phtml2
-rw-r--r--application/views/scripts/host/show.phtml13
-rw-r--r--application/views/scripts/process/source.phtml25
-rw-r--r--application/views/scripts/service/show.phtml14
24 files changed, 3742 insertions, 0 deletions
diff --git a/application/clicommands/CheckCommand.php b/application/clicommands/CheckCommand.php
new file mode 100644
index 0000000..d1c561f
--- /dev/null
+++ b/application/clicommands/CheckCommand.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Clicommands;
+
+class CheckCommand extends ProcessCommand
+{
+ public function listActions()
+ {
+ return array('process');
+ }
+
+ /**
+ * 'check process' is DEPRECATED, please use 'process check' instead
+ *
+ * USAGE
+ *
+ * icingacli businessprocess check process [--config <name>] <process>
+ */
+ public function processAction()
+ {
+ $this->checkAction();
+ }
+}
diff --git a/application/clicommands/CleanupCommand.php b/application/clicommands/CleanupCommand.php
new file mode 100644
index 0000000..f0041c8
--- /dev/null
+++ b/application/clicommands/CleanupCommand.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Clicommands;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Application\Modules\Module;
+use Icinga\Cli\Command;
+use Icinga\Module\Businessprocess\Modification\NodeRemoveAction;
+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;
+
+class CleanupCommand extends Command
+{
+ /**
+ * @var LegacyStorage
+ */
+ protected $storage;
+
+ protected $defaultActionName = 'cleanup';
+
+ public function init()
+ {
+ $this->storage = LegacyStorage::getInstance();
+ }
+
+ /**
+ * Cleanup all missing monitoring nodes from the specified config name
+ * If no config name is specified, the missing nodes are cleaned from all available configs.
+ * Invalid config files and file names are ignored
+ *
+ * USAGE
+ *
+ * icingacli businessprocess cleanup [<config-name>]
+ *
+ * OPTIONS
+ *
+ * <config-name>
+ */
+ public function cleanupAction(): void
+ {
+ $configNames = (array) $this->params->shift() ?: $this->storage->listAllProcessNames();
+ $foundMissingNode = false;
+ foreach ($configNames as $configName) {
+ if (! $this->storage->hasProcess($configName)) {
+ continue;
+ }
+
+ try {
+ $bp = $this->storage->loadProcess($configName);
+ } catch (Exception $e) {
+ Logger::error(
+ 'Failed to scan the %s.conf file for missing nodes. Faulty config found.',
+ $configName
+ );
+
+ continue;
+ }
+
+ if (Module::exists('icingadb')
+ && (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($bp);
+ } else {
+ MonitoringState::apply($bp);
+ }
+
+ $removedNodes = [];
+ foreach (array_keys($bp->getMissingChildren()) as $missingNode) {
+ $node = $bp->getNode($missingNode);
+ $remove = new NodeRemoveAction($node);
+
+ try {
+ if ($remove->appliesTo($bp)) {
+ $remove->applyTo($bp);
+ $removedNodes[] = $node->getName();
+ $this->storage->storeProcess($bp);
+ $bp->clearAppliedChanges();
+
+ $foundMissingNode = true;
+ }
+ } catch (Exception $e) {
+ Logger::error(sprintf('(%s.conf) %s', $configName, $e->getMessage()));
+
+ continue;
+ }
+ }
+
+ if (! empty($removedNodes)) {
+ echo sprintf(
+ 'Removed following %d missing node(s) from %s.conf successfully:',
+ count($removedNodes),
+ $configName
+ );
+
+ echo "\n" . implode("\n", $removedNodes) . "\n\n";
+ }
+ }
+
+ if (! $foundMissingNode) {
+ echo "No missing node found.\n";
+ }
+ }
+}
diff --git a/application/clicommands/ProcessCommand.php b/application/clicommands/ProcessCommand.php
new file mode 100644
index 0000000..018c1e3
--- /dev/null
+++ b/application/clicommands/ProcessCommand.php
@@ -0,0 +1,227 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Clicommands;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Application\Modules\Module;
+use Icinga\Cli\Command;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\HostNode;
+use Icinga\Module\Businessprocess\Node;
+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;
+
+class ProcessCommand extends Command
+{
+ /**
+ * @var LegacyStorage
+ */
+ protected $storage;
+
+ protected $hostColors = array(
+ 0 => array('black', 'lightgreen'),
+ 1 => array('lightgray', 'lightred'),
+ 2 => array('black', 'brown'),
+ 99 => array('black', 'lightgray'),
+ );
+
+ protected $serviceColors = array(
+ 0 => array('black', 'lightgreen'),
+ 1 => array('black', 'yellow'),
+ 2 => array('lightgray', 'lightred'),
+ 3 => array('black', 'lightpurple'),
+ 99 => array('black', 'lightgray'),
+ );
+
+ public function init()
+ {
+ $this->storage = LegacyStorage::getInstance();
+ }
+
+ /**
+ * List all available Business Process Configurations
+ *
+ * ...or their BusinessProcess Nodes in case a Configuration name is given
+ *
+ * USAGE
+ *
+ * icingacli businessprocess list processes [<config-name>] [options]
+ *
+ * OPTIONS
+ *
+ * <config-name>
+ * --no-title Show only names and no related title
+ */
+ public function listAction()
+ {
+ if ($config = $this->params->shift()) {
+ $this->listBpNames($this->storage->loadProcess($config));
+ } else {
+ $this->listConfigNames(! (bool) $this->params->shift('no-title'));
+ }
+ }
+
+ protected function listConfigNames($withTitle)
+ {
+ foreach ($this->storage->listProcesses() as $key => $title) {
+ if ($withTitle) {
+ echo $title . "\n";
+ } else {
+ echo $key . "\n";
+ }
+ }
+ }
+
+ /**
+ * Check a specific process
+ *
+ * USAGE
+ *
+ * icingacli businessprocess process check <process> [options]
+ *
+ * OPTIONS
+ *
+ * --config <configname> Name of the config that contains <process>
+ * --details Show problem details as a tree
+ * --colors Show colored output
+ * --state-type <type> Define which state type to look at. Could be
+ * either soft or hard, overrides an eventually
+ * configured default
+ * --blame Show problem details as a tree reduced to the
+ * nodes which have the same state as the business
+ * process
+ * --root-cause Used in combination with --blame. Only shows
+ * the path of the nodes which are responsible for
+ * the state of the business process
+ * --downtime-is-ok Treat hosts/services in downtime always as
+ * UP/OK.
+ * --ack-is-ok Treat acknowledged hosts/services always as
+ * UP/OK.
+ */
+ public function checkAction()
+ {
+ $nodeName = $this->params->shift();
+ if (! $nodeName) {
+ Logger::error('A process name is required');
+ exit(1);
+ }
+
+ $name = $this->params->get('config');
+ try {
+ if ($name === null) {
+ $name = $this->getFirstProcessName();
+ }
+
+ $bp = $this->storage->loadProcess($name);
+ } catch (Exception $err) {
+ Logger::error("Can't access configuration '%s': %s", $name, $err->getMessage());
+
+ exit(3);
+ }
+
+ if (null !== ($stateType = $this->params->get('state-type'))) {
+ if ($stateType === 'soft') {
+ $bp->useSoftStates();
+ }
+ if ($stateType === 'hard') {
+ $bp->useHardStates();
+ }
+ }
+
+ try {
+ /** @var BpNode $node */
+ $node = $bp->getNode($nodeName);
+ if (Module::exists('icingadb')
+ && (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($bp);
+ } else {
+ MonitoringState::apply($bp);
+ }
+
+ if ($bp->hasErrors()) {
+ Logger::error("Checking Business Process '%s' failed: %s\n", $name, $bp->getErrors());
+
+ exit(3);
+ }
+ } catch (Exception $err) {
+ Logger::error("Checking Business Process '%s' failed: %s", $name, $err);
+
+ exit(3);
+ }
+
+ if ($this->params->shift('ack-is-ok')) {
+ Node::setAckIsOk();
+ }
+
+ if ($this->params->shift('downtime-is-ok')) {
+ Node::setDowntimeIsOk();
+ }
+
+ printf("Business Process %s: %s\n", $node->getStateName(), $node->getAlias());
+ if ($this->params->shift('details')) {
+ echo $this->renderProblemTree($node->getProblemTree(), $this->params->shift('colors'));
+ }
+ if ($this->params->shift('blame')) {
+ echo $this->renderProblemTree(
+ $node->getProblemTreeBlame($this->params->shift('root-cause')),
+ $this->params->shift('colors')
+ );
+ }
+
+ exit($node->getState());
+ }
+
+ protected function listBpNames(BpConfig $config)
+ {
+ foreach ($config->listBpNodes() as $title) {
+ echo $title . "\n";
+ }
+ }
+
+ protected function renderProblemTree($tree, $useColors = false, $depth = 0, BpNode $parent = null)
+ {
+ $output = '';
+
+ foreach ($tree as $name => $subtree) {
+ /** @var Node $node */
+ $node = $subtree['node'];
+ $state = $parent !== null ? $parent->getChildState($node) : $node->getState();
+
+ if ($node instanceof HostNode) {
+ $colors = $this->hostColors[$state];
+ } else {
+ $colors = $this->serviceColors[$state];
+ }
+
+ $state = sprintf('[%s]', $node->getStateName($state));
+ if ($useColors) {
+ $state = $this->screen->colorize($state, $colors[0], $colors[1]);
+ }
+
+ $output .= sprintf(
+ "%s%s %s %s\n",
+ str_repeat(' ', $depth),
+ $node instanceof BpNode ? $node->getOperator() : '-',
+ $state,
+ $node->getAlias()
+ );
+
+ if ($node instanceof BpNode) {
+ $output .= $this->renderProblemTree($subtree['children'], $useColors, $depth + 1, $node);
+ }
+ }
+
+ return $output;
+ }
+
+ protected function getFirstProcessName()
+ {
+ $list = $this->storage->listProcessNames();
+ return key($list);
+ }
+}
diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php
new file mode 100644
index 0000000..e22edde
--- /dev/null
+++ b/application/controllers/HostController.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Businessprocess\IcingaDbObject;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Monitoring\Controller;
+use Icinga\Module\Monitoring\DataView\DataView;
+use Icinga\Web\Url;
+use ipl\Stdlib\Filter;
+
+class HostController extends Controller
+{
+ /**
+ * True if business process prefers to use icingadb as backend for it's nodes
+ *
+ * @var bool
+ */
+ protected $isIcingadbPreferred;
+
+ protected function moduleInit()
+ {
+ $this->isIcingadbPreferred = Module::exists('icingadb')
+ && ! $this->params->has('backend')
+ && IcingadbSupport::useIcingaDbAsBackend();
+
+ if (! $this->isIcingadbPreferred) {
+ parent::moduleInit();
+ }
+ }
+
+ public function showAction()
+ {
+ if ($this->isIcingadbPreferred) {
+ $hostName = $this->params->shift('host');
+
+ $query = Host::on(IcingaDbObject::fetchDb());
+ IcingaDbObject::applyIcingaDbRestrictions($query);
+
+ $query->filter(Filter::equal('host.name', $hostName));
+
+ $host = $query->first();
+
+ $this->params->add('name', $hostName);
+
+ if ($host !== null) {
+ $this->redirectNow(Url::fromPath('icingadb/host')->setParams($this->params));
+ }
+ } else {
+ $hostName = $this->params->get('host');
+
+ $query = $this->backend->select()
+ ->from('hoststatus', array('host_name'))
+ ->where('host_name', $hostName);
+
+ $this->applyRestriction('monitoring/filter/objects', $query);
+ if ($query->fetchRow() !== false) {
+ $this->redirectNow(Url::fromPath('monitoring/host/show')->setParams($this->params));
+ }
+ }
+
+ $this->view->host = $hostName;
+ }
+}
diff --git a/application/controllers/IndexController.php b/application/controllers/IndexController.php
new file mode 100644
index 0000000..60ddc70
--- /dev/null
+++ b/application/controllers/IndexController.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Module\Businessprocess\Web\Controller;
+use Icinga\Module\Businessprocess\Web\Component\Dashboard;
+
+class IndexController extends Controller
+{
+ /**
+ * Show an overview page
+ */
+ public function indexAction()
+ {
+ $this->setTitle($this->translate('Business Process Overview'));
+ $this->controls()->add($this->overviewTab());
+ $this->content()->add(Dashboard::create($this->Auth(), $this->storage()));
+ $this->setAutorefreshInterval(15);
+ }
+}
diff --git a/application/controllers/NodeController.php b/application/controllers/NodeController.php
new file mode 100644
index 0000000..e5c657f
--- /dev/null
+++ b/application/controllers/NodeController.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Exception;
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Businessprocess\Renderer\Breadcrumb;
+use Icinga\Module\Businessprocess\Renderer\TileRenderer;
+use Icinga\Module\Businessprocess\Simulation;
+use Icinga\Module\Businessprocess\State\IcingaDbState;
+use Icinga\Module\Businessprocess\State\MonitoringState;
+use Icinga\Module\Businessprocess\Web\Controller;
+use Icinga\Module\Businessprocess\Web\Url;
+use ipl\Html\Html;
+use ipl\Web\Widget\Link;
+
+class NodeController extends Controller
+{
+ public function impactAction()
+ {
+ $this->setAutorefreshInterval(10);
+ $content = $this->content();
+ $this->controls()->add(
+ $this->singleTab($this->translate('Node Impact'))
+ );
+ $name = $this->params->get('name');
+ $this->addTitle($this->translate('Business Impact (%s)'), $name);
+
+ $brokenFiles = [];
+ $simulation = Simulation::fromSession($this->session());
+ foreach ($this->storage()->listProcessNames() as $configName) {
+ try {
+ $config = $this->storage()->loadProcess($configName);
+ } catch (Exception $e) {
+ $meta = $this->storage()->loadMetadata($configName);
+ $brokenFiles[$meta->get('Title')] = $configName;
+ continue;
+ }
+
+ $parents = [];
+ if ($config->hasNode($name)) {
+ foreach ($config->getNode($name)->getPaths() as $path) {
+ array_pop($path); // Remove the monitored node
+ $immediateParentName = array_pop($path); // The directly affected process
+ $parents[] = [$config->getNode($immediateParentName), $path];
+ }
+ }
+
+ $askedConfigs = [];
+ foreach ($config->getImportedNodes() as $importedNode) {
+ $importedConfig = $importedNode->getBpConfig();
+
+ if (isset($askedConfigs[$importedConfig->getName()])) {
+ continue;
+ } else {
+ $askedConfigs[$importedConfig->getName()] = true;
+ }
+
+ if ($importedConfig->hasNode($name)) {
+ $node = $importedConfig->getNode($name);
+ $nativePaths = $node->getPaths($config);
+
+ do {
+ $path = array_pop($nativePaths);
+ $importedNodePos = array_search($importedNode->getIdentifier(), $path, true);
+ if ($importedNodePos !== false) {
+ array_pop($path); // Remove the monitored node
+ $immediateParentName = array_pop($path); // The directly affected process
+ $importedPath = array_slice($path, $importedNodePos + 1);
+
+ // We may get multiple native paths. Though, only the right hand of the path
+ // is what we're interested in. The left part is not what is getting imported.
+ $antiDuplicator = join('|', $importedPath) . '|' . $immediateParentName;
+ if (isset($parents[$antiDuplicator])) {
+ continue;
+ }
+
+ foreach ($importedNode->getPaths($config) as $targetPath) {
+ if ($targetPath[count($targetPath) - 1] === $immediateParentName) {
+ array_pop($targetPath);
+ $parent = $importedNode;
+ } else {
+ $parent = $importedConfig->getNode($immediateParentName);
+ }
+
+ $parents[$antiDuplicator] = [$parent, array_merge($targetPath, $importedPath)];
+ }
+ }
+ } while (! empty($nativePaths));
+ }
+ }
+
+ if (empty($parents)) {
+ continue;
+ }
+
+ if (Module::exists('icingadb') &&
+ (! $config->getBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($config);
+ } else {
+ MonitoringState::apply($config);
+ }
+ $config->applySimulation($simulation);
+
+ foreach ($parents as $parentAndPath) {
+ $renderer = (new TileRenderer($config, array_shift($parentAndPath)))
+ ->setUrl(Url::fromPath('businessprocess/process/show', ['config' => $configName]))
+ ->setPath(array_shift($parentAndPath));
+
+ $bc = Breadcrumb::create($renderer);
+ $bc->getAttributes()->set('data-base-target', '_next');
+ $content->add($bc);
+ }
+ }
+
+ if ($content->isEmpty()) {
+ $content->add($this->translate('No impact detected. Is this node part of a business process?'));
+ }
+
+ if (! empty($brokenFiles)) {
+ $elem = Html::tag(
+ 'ul',
+ ['class' => 'broken-files'],
+ tp(
+ 'The following business process has an invalid config file and therefore cannot be read:',
+ 'The following business processes have invalid config files and therefore cannot be read:',
+ count($brokenFiles)
+ )
+ );
+
+ foreach ($brokenFiles as $bpName => $fileName) {
+ $elem->addHtml(
+ Html::tag(
+ 'li',
+ new Link(
+ sprintf('%s (%s.conf)', $bpName, $fileName),
+ \ipl\Web\Url::fromPath('businessprocess/process/show', ['config' => $fileName])
+ )
+ )
+ );
+ }
+
+ $content->addHtml($elem);
+ }
+ }
+}
diff --git a/application/controllers/ProcessController.php b/application/controllers/ProcessController.php
new file mode 100644
index 0000000..208c91e
--- /dev/null
+++ b/application/controllers/ProcessController.php
@@ -0,0 +1,780 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Forms\AddNodeForm;
+use Icinga\Module\Businessprocess\Forms\EditNodeForm;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Businessprocess\Renderer\Breadcrumb;
+use Icinga\Module\Businessprocess\Renderer\Renderer;
+use Icinga\Module\Businessprocess\Renderer\TileRenderer;
+use Icinga\Module\Businessprocess\Renderer\TreeRenderer;
+use Icinga\Module\Businessprocess\Simulation;
+use Icinga\Module\Businessprocess\State\IcingaDbState;
+use Icinga\Module\Businessprocess\State\MonitoringState;
+use Icinga\Module\Businessprocess\Storage\ConfigDiff;
+use Icinga\Module\Businessprocess\Storage\LegacyConfigRenderer;
+use Icinga\Module\Businessprocess\Web\Component\ActionBar;
+use Icinga\Module\Businessprocess\Web\Component\RenderedProcessActionBar;
+use Icinga\Module\Businessprocess\Web\Component\Tabs;
+use Icinga\Module\Businessprocess\Web\Controller;
+use Icinga\Util\Json;
+use Icinga\Web\Notification;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Tabextension\DashboardAction;
+use Icinga\Web\Widget\Tabextension\OutputFormat;
+use ipl\Html\Form;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Web\Control\SortControl;
+use ipl\Web\FormElement\TermInput;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\Icon;
+
+class ProcessController extends Controller
+{
+ /** @var Renderer */
+ protected $renderer;
+
+ /**
+ * Create a new Business Process Configuration
+ */
+ public function createAction()
+ {
+ $this->assertPermission('businessprocess/create');
+
+ $title = $this->translate('Create a new Business Process');
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForCreate()->activate('create'))
+ ->add(Html::tag('h1', null, $title));
+
+ $this->content()->add(
+ $this->loadForm('bpConfig')
+ ->setStorage($this->storage())
+ ->setSuccessUrl('businessprocess/process/show')
+ ->handleRequest()
+ );
+ }
+
+ /**
+ * Upload an existing Business Process Configuration
+ */
+ public function uploadAction()
+ {
+ $this->assertPermission('businessprocess/create');
+
+ $title = $this->translate('Upload a Business Process Config file');
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForCreate()->activate('upload'))
+ ->add(Html::tag('h1', null, $title));
+
+ $this->content()->add(
+ $this->loadForm('BpUpload')
+ ->setStorage($this->storage())
+ ->setSuccessUrl('businessprocess/process/show')
+ ->handleRequest()
+ );
+ }
+
+ /**
+ * Show a business process
+ */
+ public function showAction()
+ {
+ $bp = $this->loadModifiedBpConfig();
+ $node = $this->getNode($bp);
+
+ if (Module::exists('icingadb') &&
+ (! $bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend())
+ ) {
+ IcingaDbState::apply($bp);
+ } else {
+ MonitoringState::apply($bp);
+ }
+
+ $this->handleSimulations($bp);
+
+ $this->setTitle($this->translate('Business Process "%s"'), $bp->getTitle());
+
+ $renderer = $this->prepareRenderer($bp, $node);
+
+ if (! $this->showFullscreen && ($node === null || ! $renderer->rendersImportedNode())) {
+ if ($this->params->get('unlocked')) {
+ $renderer->unlock();
+ }
+
+ if ($bp->isEmpty() && $renderer->isLocked()) {
+ $this->redirectNow($this->url()->with('unlocked', true));
+ }
+ }
+
+ $this->handleFormatRequest($bp, $node);
+
+ $this->prepareControls($bp, $renderer);
+
+ $this->tabs()->extend(new OutputFormat());
+
+ $this->content()->add($this->showHints($bp, $renderer));
+ $this->content()->add($this->showWarnings($bp));
+ $this->content()->add($this->showErrors($bp));
+ $this->content()->add($renderer);
+ $this->loadActionForm($bp, $node);
+ $this->setDynamicAutorefresh();
+ }
+
+ /**
+ * Create a sort control and apply its sort specification to the given renderer
+ *
+ * @param Renderer $renderer
+ * @param BpConfig $config
+ *
+ * @return SortControl
+ */
+ protected function createBpSortControl(Renderer $renderer, BpConfig $config): SortControl
+ {
+ $defaultSort = $this->session()->get('sort.default', $renderer->getDefaultSort());
+ $options = [
+ 'display_name asc' => $this->translate('Name'),
+ 'state desc' => $this->translate('State')
+ ];
+ if ($config->getMetadata()->isManuallyOrdered()) {
+ $options['manual asc'] = $this->translate('Manual');
+ } elseif ($defaultSort === 'manual desc') {
+ $defaultSort = $renderer->getDefaultSort();
+ }
+
+ $sortControl = SortControl::create($options)
+ ->setDefault($defaultSort)
+ ->setMethod('POST')
+ ->setAttribute('name', 'bp-sort-control')
+ ->on(Form::ON_SUCCESS, function (SortControl $sortControl) use ($renderer) {
+ $sort = $sortControl->getSort();
+ if ($sort === $renderer->getDefaultSort()) {
+ $this->session()->delete('sort.default');
+ $url = Url::fromRequest()->without($sortControl->getSortParam());
+ } else {
+ $this->session()->set('sort.default', $sort);
+ $url = Url::fromRequest()->with($sortControl->getSortParam(), $sort);
+ }
+
+ $this->redirectNow($url);
+ })->handleRequest($this->getServerRequest());
+
+ $renderer->setSort($sortControl->getSort());
+ $this->params->shift($sortControl->getSortParam());
+
+ return $sortControl;
+ }
+
+ protected function prepareControls($bp, $renderer)
+ {
+ $controls = $this->controls();
+
+ if ($this->showFullscreen) {
+ $controls->getAttributes()->add('class', 'want-fullscreen');
+ $controls->add(Html::tag(
+ 'a',
+ [
+ 'href' => $this->url()->without('showFullscreen')->without('view'),
+ 'title' => $this->translate('Leave full screen and switch back to normal mode')
+ ],
+ new Icon('down-left-and-up-right-to-center')
+ ));
+ }
+
+ if (! ($this->showFullscreen || $this->view->compact)) {
+ $controls->add($this->getProcessTabs($bp, $renderer));
+ $controls->getAttributes()->add('class', 'separated');
+ }
+
+ $controls->add(Breadcrumb::create(clone $renderer));
+ if (! $this->showFullscreen && ! $this->view->compact) {
+ $controls->add(
+ new RenderedProcessActionBar($bp, $renderer, $this->url())
+ );
+ }
+
+ $controls->addHtml($this->createBpSortControl($renderer, $bp));
+ }
+
+ protected function getNode(BpConfig $bp)
+ {
+ if ($nodeName = $this->params->get('node')) {
+ return $bp->getNode($nodeName);
+ } else {
+ return null;
+ }
+ }
+
+ protected function prepareRenderer($bp, $node)
+ {
+ if ($this->renderer === null) {
+ if ($this->params->get('mode') === 'tree') {
+ $renderer = new TreeRenderer($bp, $node);
+ } else {
+ $renderer = new TileRenderer($bp, $node);
+ }
+ $renderer->setUrl($this->url())
+ ->setPath($this->params->getValues('path'));
+
+ $this->renderer = $renderer;
+ }
+
+ return $this->renderer;
+ }
+
+ protected function getProcessTabs(BpConfig $bp, Renderer $renderer)
+ {
+ $tabs = $this->singleTab($bp->getTitle());
+ if ($renderer->isLocked()) {
+ $tabs->extend(new DashboardAction());
+ }
+
+ return $tabs;
+ }
+
+ protected function handleSimulations(BpConfig $bp)
+ {
+ $simulation = Simulation::fromSession($this->session());
+
+ if ($this->params->get('dismissSimulations')) {
+ Notification::success(
+ sprintf(
+ $this->translate('%d applied simulation(s) have been dropped'),
+ $simulation->count()
+ )
+ );
+ $simulation->clear();
+ $this->redirectNow($this->url()->without('dismissSimulations')->without('unlocked'));
+ }
+
+ $bp->applySimulation($simulation);
+ }
+
+ protected function loadActionForm(BpConfig $bp, Node $node = null)
+ {
+ $action = $this->params->get('action');
+ $form = null;
+ if ($this->showFullscreen) {
+ return;
+ }
+
+ $canEdit = $bp->getMetadata()->canModify();
+
+ if ($action === 'add' && $canEdit) {
+ $form = (new AddNodeForm())
+ ->setProcess($bp)
+ ->setParentNode($node)
+ ->setStorage($this->storage())
+ ->setSession($this->session())
+ ->on(AddNodeForm::ON_SUCCESS, function () {
+ $this->redirectNow(Url::fromRequest()->without('action'));
+ })
+ ->handleRequest($this->getServerRequest());
+
+ if ($form->hasElement('children')) {
+ /** @var TermInput $childrenElement */
+ $childrenElement = $form->getElement('children');
+ foreach ($childrenElement->prepareMultipartUpdate($this->getServerRequest()) as $update) {
+ if (! is_array($update)) {
+ $update = [$update];
+ }
+
+ $this->addPart(...$update);
+ }
+ }
+ } elseif ($action === 'cleanup' && $canEdit) {
+ $form = $this->loadForm('CleanupNode')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setProcess($bp)
+ ->setSession($this->session())
+ ->handleRequest();
+ } elseif ($action === 'editmonitored' && $canEdit) {
+ $form = (new EditNodeForm())
+ ->setProcess($bp)
+ ->setNode($bp->getNode($this->params->get('editmonitorednode')))
+ ->setParentNode($node)
+ ->setSession($this->session())
+ ->on(EditNodeForm::ON_SUCCESS, function () {
+ $this->redirectNow(Url::fromRequest()->without(['action', 'editmonitorednode']));
+ })
+ ->handleRequest($this->getServerRequest());
+ } elseif ($action === 'delete' && $canEdit) {
+ $form = $this->loadForm('DeleteNode')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setProcess($bp)
+ ->setNode($bp->getNode($this->params->get('deletenode')))
+ ->setParentNode($node)
+ ->setSession($this->session())
+ ->handleRequest();
+ } elseif ($action === 'edit' && $canEdit) {
+ $form = $this->loadForm('Process')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setProcess($bp)
+ ->setNode($bp->getNode($this->params->get('editnode')))
+ ->setSession($this->session())
+ ->handleRequest();
+ } elseif ($action === 'simulation') {
+ $form = $this->loadForm('simulation')
+ ->setSuccessUrl(Url::fromRequest()->without('action'))
+ ->setNode($bp->getNode($this->params->get('simulationnode')))
+ ->setSimulation(Simulation::fromSession($this->session()))
+ ->handleRequest();
+ } elseif ($action === 'move') {
+ $successUrl = $this->url()->without(['action', 'movenode']);
+ if ($this->params->get('mode') === 'tree') {
+ // If the user moves a node from a subtree, the `node` param exists
+ $successUrl->getParams()->remove('node');
+ }
+
+ if ($this->session()->get('sort.default')) {
+ // If there's a default sort specification in the session, it can only be `display_name desc`,
+ // as otherwise the user wouldn't be able to trigger this action. So it's safe to just define
+ // descending manual order now.
+ $successUrl->getParams()->add(SortControl::DEFAULT_SORT_PARAM, 'manual desc');
+ }
+
+ $form = $this->loadForm('MoveNode')
+ ->setSuccessUrl($successUrl)
+ ->setProcess($bp)
+ ->setParentNode($node)
+ ->setSession($this->session())
+ ->setNode($bp->getNode($this->params->get('movenode')))
+ ->handleRequest();
+ }
+
+ if ($form) {
+ $this->content()->prepend(HtmlString::create((string) $form));
+ }
+ }
+
+ protected function setDynamicAutorefresh()
+ {
+ if (! $this->isXhr()) {
+ // This will trigger the very first XHR refresh immediately on page
+ // load. Please not that this may hammer the server in case we would
+ // decide to use autorefreshInterval for HTML meta-refreshes also.
+ $this->setAutorefreshInterval(1);
+ return;
+ }
+
+ if ($this->params->has('action')) {
+ if ($this->params->get('action') !== 'add') {
+ // The new add form uses the term input, which doesn't support value persistence across refreshes
+ $this->setAutorefreshInterval(45);
+ }
+ } else {
+ $this->setAutorefreshInterval(10);
+ }
+ }
+
+ protected function showWarnings(BpConfig $bp)
+ {
+ if ($bp->hasWarnings()) {
+ $ul = Html::tag('ul', array('class' => 'warning'));
+ foreach ($bp->getWarnings() as $warning) {
+ $ul->add(Html::tag('li')->setContent($warning));
+ }
+
+ return $ul;
+ } else {
+ return null;
+ }
+ }
+
+ protected function showErrors(BpConfig $bp)
+ {
+ if ($bp->hasWarnings()) {
+ $ul = Html::tag('ul', array('class' => 'error'));
+ foreach ($bp->getErrors() as $msg) {
+ $ul->add(Html::tag('li')->setContent($msg));
+ }
+
+ return $ul;
+ } else {
+ return null;
+ }
+ }
+
+ protected function showHints(BpConfig $bp, Renderer $renderer)
+ {
+ $ul = Html::tag('ul', ['class' => 'error']);
+ $this->prepareMissingNodeLinks($ul);
+ foreach ($bp->getErrors() as $error) {
+ $ul->addHtml(Html::tag('li', $error));
+ }
+
+ if ($bp->hasChanges()) {
+ $li = Html::tag('li')->setSeparator(' ');
+ $li->add(sprintf(
+ $this->translate('This process has %d pending change(s).'),
+ $bp->countChanges()
+ ))->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/config')
+ ->setParams($this->getRequest()->getUrl()->getParams())
+ ],
+ $this->translate('Store')
+ ))->add(Html::tag(
+ 'a',
+ ['href' => $this->url()->with('dismissChanges', true)],
+ $this->translate('Dismiss')
+ ));
+ $ul->add($li);
+ }
+
+ if ($bp->hasSimulations()) {
+ $li = Html::tag('li')->setSeparator(' ');
+ $li->add(sprintf(
+ $this->translate('This process shows %d simulated state(s).'),
+ $bp->countSimulations()
+ ))->add(Html::tag(
+ 'a',
+ ['href' => $this->url()->with('dismissSimulations', true)],
+ $this->translate('Dismiss')
+ ));
+ $ul->add($li);
+ }
+
+ if (! $renderer->isLocked() && $renderer->appliesCustomSorting()) {
+ $ul->addHtml(Html::tag('li', null, [
+ Text::create($this->translate('Drag&Drop disabled. Custom sort order applied.')),
+ (new Form())
+ ->setAttribute('class', 'inline')
+ ->addElement('submitButton', SortControl::DEFAULT_SORT_PARAM, [
+ 'label' => $this->translate('Reset to default'),
+ 'value' => $renderer->getDefaultSort(),
+ 'class' => 'link-button'
+ ])
+ ->addElement('hidden', 'uid', ['value' => 'bp-sort-control'])
+ ])->setSeparator(' '));
+ }
+
+ if (! $ul->isEmpty()) {
+ return $ul;
+ } else {
+ return null;
+ }
+ }
+
+ protected function prepareMissingNodeLinks(HtmlElement $ul): void
+ {
+ $missing = array_keys($this->bp->getMissingChildren());
+ if (! empty($missing)) {
+ $missingLinkedNodes = null;
+ foreach ($this->bp->getImportedNodes() as $process) {
+ if ($process->hasMissingChildren()) {
+ $missingLinkedNodes = array_keys($process->getMissingChildren());
+ $link = Url::fromPath('businessprocess/process/show')
+ ->addParams(['config' => $process->getConfigName()]);
+
+ $ul->addHtml(Html::tag(
+ 'li',
+ [
+ TemplateString::create(
+ tp(
+ 'Linked node %s has one missing child node: {{#link}}Show{{/link}}',
+ 'Linked node %s has %d missing child nodes: {{#link}}Show{{/link}}',
+ count($missingLinkedNodes)
+ ),
+ $process->getAlias(),
+ count($missingLinkedNodes),
+ ['link' => new Link(null, (string) $link)]
+ )
+ ]
+ ));
+ }
+ }
+
+ if (! empty($missingLinkedNodes)) {
+ return;
+ }
+
+ $count = count($missing);
+ if ($count > 10) {
+ $missing = array_slice($missing, 0, 10);
+ $missing[] = '...';
+ }
+
+ $link = Url::fromPath('businessprocess/process/show')
+ ->addParams(['config' => $this->bp->getName(), 'action' => 'cleanup']);
+
+ $ul->addHtml(Html::tag(
+ 'li',
+ [
+ TemplateString::create(
+ tp(
+ '{{#link}}Cleanup{{/link}} one missing node: %2$s',
+ '{{#link}}Cleanup{{/link}} %d missing nodes: %s',
+ count($missing)
+ ),
+ ['link' => new Link(null, (string) $link)],
+ $count,
+ implode(', ', $missing)
+ )
+ ]
+ ));
+ }
+ }
+
+ /**
+ * Show the source code for a process
+ */
+ public function sourceAction()
+ {
+ $this->assertPermission('businessprocess/modify');
+
+ $bp = $this->loadModifiedBpConfig();
+ $this->view->showDiff = $showDiff = (bool) $this->params->get('showDiff', false);
+
+ $this->view->source = LegacyConfigRenderer::renderConfig($bp);
+ if ($this->view->showDiff) {
+ $this->view->diff = ConfigDiff::create(
+ $this->storage()->getSource($this->view->configName),
+ $this->view->source
+ );
+ $title = sprintf(
+ $this->translate('%s: Source Code Differences'),
+ $bp->getTitle()
+ );
+ } else {
+ $title = sprintf(
+ $this->translate('%s: Source Code'),
+ $bp->getTitle()
+ );
+ }
+
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForConfig($bp)->activate('source'))
+ ->add(Html::tag('h1', null, $title))
+ ->add($this->createConfigActionBar($bp, $showDiff));
+
+ $this->setViewScript('process/source');
+ }
+
+ /**
+ * Download a process configuration file
+ */
+ public function downloadAction()
+ {
+ $this->assertPermission('businessprocess/modify');
+
+ $config = $this->loadModifiedBpConfig();
+ $response = $this->getResponse();
+ $response->setHeader(
+ 'Content-Disposition',
+ sprintf(
+ 'attachment; filename="%s.conf";',
+ $config->getName()
+ )
+ );
+ $response->setHeader('Content-Type', 'text/plain');
+
+ echo LegacyConfigRenderer::renderConfig($config);
+ $this->doNotRender();
+ }
+
+ /**
+ * Modify a business process configuration
+ */
+ public function configAction()
+ {
+ $this->assertPermission('businessprocess/modify');
+
+ $bp = $this->loadModifiedBpConfig();
+
+ $title = sprintf(
+ $this->translate('%s: Configuration'),
+ $bp->getTitle()
+ );
+ $this->setTitle($title);
+ $this->controls()
+ ->add($this->tabsForConfig($bp)->activate('config'))
+ ->add(Html::tag('h1', null, $title))
+ ->add($this->createConfigActionBar($bp));
+
+ $url = Url::fromPath('businessprocess/process/show')
+ ->setParams($this->getRequest()->getUrl()->getParams());
+ $this->content()->add(
+ $this->loadForm('bpConfig')
+ ->setProcess($bp)
+ ->setStorage($this->storage())
+ ->setSuccessUrl($url)
+ ->handleRequest()
+ );
+ }
+
+ protected function createConfigActionBar(BpConfig $config, $showDiff = false)
+ {
+ $actionBar = new ActionBar();
+
+ if ($showDiff) {
+ $params = array('config' => $config->getName());
+ $actionBar->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/source', $params),
+ 'title' => $this->translate('Show source code')
+ ],
+ [
+ new Icon('file-lines'),
+ $this->translate('Source'),
+ ]
+ ));
+ } else {
+ $params = array(
+ 'config' => $config->getName(),
+ 'showDiff' => true
+ );
+
+ $actionBar->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/source', $params),
+ 'title' => $this->translate('Highlight changes')
+ ],
+ [
+ new Icon('shuffle'),
+ $this->translate('Diff')
+ ]
+ ));
+ }
+
+ $actionBar->add(Html::tag(
+ 'a',
+ [
+ 'href' => Url::fromPath('businessprocess/process/download', ['config' => $config->getName()]),
+ 'target' => '_blank',
+ 'title' => $this->translate('Download process configuration')
+ ],
+ [
+ new Icon('download'),
+ $this->translate('Download')
+ ]
+ ));
+
+ return $actionBar;
+ }
+
+ protected function tabsForShow()
+ {
+ return $this->tabs()->add('show', array(
+ 'label' => $this->translate('Business Process'),
+ 'url' => $this->url()
+ ));
+ }
+
+ /**
+ * @return Tabs
+ */
+ protected function tabsForCreate()
+ {
+ return $this->tabs()->add('create', array(
+ 'label' => $this->translate('Create'),
+ 'url' => 'businessprocess/process/create'
+ ))->add('upload', array(
+ 'label' => $this->translate('Upload'),
+ 'url' => 'businessprocess/process/upload'
+ ));
+ }
+
+ protected function tabsForConfig(BpConfig $config)
+ {
+ $params = array(
+ 'config' => $config->getName()
+ );
+
+ $tabs = $this->tabs()->add('config', array(
+ 'label' => $this->translate('Process Configuration'),
+ 'url' =>Url::fromPath('businessprocess/process/config', $params)
+ ));
+
+ if ($this->params->get('showDiff')) {
+ $params['showDiff'] = true;
+ }
+
+ $tabs->add('source', array(
+ 'label' => $this->translate('Source'),
+ 'url' =>Url::fromPath('businessprocess/process/source', $params)
+ ));
+
+ return $tabs;
+ }
+
+ protected function handleFormatRequest(BpConfig $bp, BpNode $node = null)
+ {
+ $desiredContentType = $this->getRequest()->getHeader('Accept');
+ if ($desiredContentType === 'application/json') {
+ $desiredFormat = 'json';
+ } elseif ($desiredContentType === 'text/csv') {
+ $desiredFormat = 'csv';
+ } else {
+ $desiredFormat = strtolower($this->params->get('format', 'html'));
+ }
+
+ switch ($desiredFormat) {
+ case 'json':
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'inline; filename=' . $this->getRequest()->getActionName() . '.json'
+ )
+ ->appendBody(Json::sanitize($node !== null ? $node->toArray() : $bp->toArray()))
+ ->sendResponse();
+ exit;
+ case 'csv':
+ $csv = fopen('php://temp', 'w');
+
+ fputcsv($csv, ['Path', 'Name', 'State', 'Since', 'In_Downtime']);
+
+ foreach ($node !== null ? $node->toArray(null, true) : $bp->toArray(true) as $node) {
+ $data = [$node['path'], $node['name']];
+
+ if (isset($node['state'])) {
+ $data[] = $node['state'];
+ }
+
+ if (isset($node['since'])) {
+ $data[] = DateFormatter::formatDateTime($node['since']);
+ }
+
+ if (isset($node['in_downtime'])) {
+ $data[] = $node['in_downtime'];
+ }
+
+ fputcsv($csv, $data);
+ }
+
+ $response = $this->getResponse();
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $this->getRequest()->getActionName() . '.csv'
+ )
+ ->sendHeaders();
+
+ rewind($csv);
+
+ fpassthru($csv);
+
+ exit;
+ }
+ }
+}
diff --git a/application/controllers/ServiceController.php b/application/controllers/ServiceController.php
new file mode 100644
index 0000000..671d00c
--- /dev/null
+++ b/application/controllers/ServiceController.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Businessprocess\IcingaDbObject;
+use Icinga\Module\Businessprocess\ProvidedHook\Icingadb\IcingadbSupport;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Monitoring\Controller;
+use Icinga\Module\Monitoring\DataView\DataView;
+use Icinga\Web\Url;
+use ipl\Stdlib\Filter;
+
+class ServiceController extends Controller
+{
+ /**
+ * True if business process prefers to use icingadb as backend for it's nodes
+ *
+ * @var bool
+ */
+ protected $isIcingadbPreferred;
+
+ protected function moduleInit()
+ {
+ $this->isIcingadbPreferred = Module::exists('icingadb')
+ && ! $this->params->has('backend')
+ && IcingadbSupport::useIcingaDbAsBackend();
+
+ if (! $this->isIcingadbPreferred) {
+ parent::moduleInit();
+ }
+ }
+
+ public function showAction()
+ {
+ if ($this->isIcingadbPreferred) {
+ $hostName = $this->params->shift('host');
+ $serviceName = $this->params->shift('service');
+
+ $query = Service::on(IcingaDbObject::fetchDb());
+ IcingaDbObject::applyIcingaDbRestrictions($query);
+
+ $query->filter(Filter::all(
+ Filter::equal('service.name', $serviceName),
+ Filter::equal('host.name', $hostName)
+ ));
+
+ $service = $query->first();
+
+ $this->params->add('name', $serviceName);
+ $this->params->add('host.name', $hostName);
+
+ if ($service !== null) {
+ $this->redirectNow(Url::fromPath('icingadb/service')->setParams($this->params));
+ }
+ } else {
+ $hostName = $this->params->get('host');
+ $serviceName = $this->params->get('service');
+
+ $query = $this->backend->select()
+ ->from('servicestatus', array('service_description'))
+ ->where('host_name', $hostName)
+ ->where('service_description', $serviceName);
+
+ $this->applyRestriction('monitoring/filter/objects', $query);
+ if ($query->fetchRow() !== false) {
+ $this->redirectNow(Url::fromPath('monitoring/service/show')->setParams($this->params));
+ }
+ }
+
+ $this->view->host = $hostName;
+ $this->view->service = $serviceName;
+ }
+}
diff --git a/application/controllers/SuggestionsController.php b/application/controllers/SuggestionsController.php
new file mode 100644
index 0000000..9fa0331
--- /dev/null
+++ b/application/controllers/SuggestionsController.php
@@ -0,0 +1,372 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Controllers;
+
+use Exception;
+use Icinga\Data\Filter\Filter as LegacyFilter;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\HostNode;
+use Icinga\Module\Businessprocess\IcingaDbObject;
+use Icinga\Module\Businessprocess\ImportedNode;
+use Icinga\Module\Businessprocess\Monitoring\DataView\HostStatus;
+use Icinga\Module\Businessprocess\Monitoring\DataView\ServiceStatus;
+use Icinga\Module\Businessprocess\MonitoringRestrictions;
+use Icinga\Module\Businessprocess\ServiceNode;
+use Icinga\Module\Businessprocess\Web\Controller;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Stdlib\Filter;
+use ipl\Web\FormElement\TermInput\TermSuggestions;
+
+class SuggestionsController extends Controller
+{
+ public function processAction()
+ {
+ $ignoreList = [];
+ $forConfig = null;
+ $forParent = null;
+ if ($this->params->has('config')) {
+ $forConfig = $this->loadModifiedBpConfig();
+
+ $parentName = $this->params->get('node');
+ if ($parentName) {
+ $forParent = $forConfig->getBpNode($parentName);
+
+ $collectParents = function ($node) use ($ignoreList, &$collectParents) {
+ foreach ($node->getParents() as $parent) {
+ $ignoreList[$parent->getName()] = true;
+
+ if ($parent->hasParents()) {
+ $collectParents($parent);
+ }
+ }
+ };
+
+ $ignoreList[$parentName] = true;
+ if ($forParent->hasParents()) {
+ $collectParents($forParent);
+ }
+
+ foreach ($forParent->getChildNames() as $name) {
+ $ignoreList[$name] = true;
+ }
+ }
+ }
+
+ $suggestions = new TermSuggestions((function () use ($forConfig, $forParent, $ignoreList, &$suggestions) {
+ foreach ($this->storage()->listProcessNames() as $config) {
+ $differentConfig = false;
+ if ($forConfig === null || $config !== $forConfig->getName()) {
+ if ($forConfig !== null && $forParent === null) {
+ continue;
+ }
+
+ try {
+ $bp = $this->storage()->loadProcess($config);
+ } catch (Exception $_) {
+ continue;
+ }
+
+ $differentConfig = true;
+ } else {
+ $bp = $forConfig;
+ }
+
+ foreach ($bp->getBpNodes() as $bpNode) {
+ /** @var BpNode $bpNode */
+ if ($bpNode instanceof ImportedNode) {
+ continue;
+ }
+
+ $search = $bpNode->getName();
+ if ($differentConfig) {
+ $search = "@$config:$search";
+ }
+
+ if (in_array($search, $suggestions->getExcludeTerms(), true)
+ || isset($ignoreList[$search])
+ || ($forParent
+ ? $forParent->hasChild($search)
+ : ($forConfig && $forConfig->hasRootNode($search))
+ )
+ ) {
+ continue;
+ }
+
+ if ($suggestions->matchSearch($bpNode->getName())
+ || (! $bpNode->hasAlias() || $suggestions->matchSearch($bpNode->getAlias()))
+ || $bpNode->getName() === $suggestions->getOriginalSearchValue()
+ || $bpNode->getAlias() === $suggestions->getOriginalSearchValue()
+ ) {
+ yield [
+ 'search' => $search,
+ 'label' => $bpNode->getAlias() ?? $bpNode->getName(),
+ 'config' => $config
+ ];
+ }
+ }
+ }
+ })());
+ $suggestions->setGroupingCallback(function (array $data) {
+ return $this->storage()->loadMetadata($data['config'])->getTitle();
+ });
+
+ $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest()));
+ }
+
+ public function icingadbHostAction()
+ {
+ $excludes = Filter::none();
+ $forConfig = null;
+ if ($this->params->has('config')) {
+ $forConfig = $this->loadModifiedBpConfig();
+
+ if ($this->params->has('node')) {
+ $nodeName = $this->params->get('node');
+ $node = $forConfig->getBpNode($nodeName);
+
+ foreach ($node->getChildren() as $child) {
+ if ($child instanceof HostNode) {
+ $excludes->add(Filter::equal('host.name', $child->getHostname()));
+ }
+ }
+ }
+ }
+
+ $suggestions = new TermSuggestions((function () use ($forConfig, $excludes, &$suggestions) {
+ foreach ($suggestions->getExcludeTerms() as $excludeTerm) {
+ [$hostName, $_] = BpConfig::splitNodeName($excludeTerm);
+ $excludes->add(Filter::equal('host.name', $hostName));
+ }
+
+ $hosts = Host::on($forConfig->getBackend())
+ ->columns(['host.name', 'host.display_name'])
+ ->limit(50);
+ IcingaDbObject::applyIcingaDbRestrictions($hosts);
+ $hosts->filter(Filter::all(
+ $excludes,
+ Filter::any(
+ Filter::like('host.name', $suggestions->getSearchTerm()),
+ Filter::equal('host.name', $suggestions->getOriginalSearchValue()),
+ Filter::like('host.display_name', $suggestions->getSearchTerm()),
+ Filter::equal('host.display_name', $suggestions->getOriginalSearchValue()),
+ Filter::like('host.address', $suggestions->getSearchTerm()),
+ Filter::equal('host.address', $suggestions->getOriginalSearchValue()),
+ Filter::like('host.address6', $suggestions->getSearchTerm()),
+ Filter::equal('host.address6', $suggestions->getOriginalSearchValue()),
+ Filter::like('host.customvar_flat.flatvalue', $suggestions->getSearchTerm()),
+ Filter::equal('host.customvar_flat.flatvalue', $suggestions->getOriginalSearchValue()),
+ Filter::like('hostgroup.name', $suggestions->getSearchTerm()),
+ Filter::equal('hostgroup.name', $suggestions->getOriginalSearchValue())
+ )
+ ));
+ foreach ($hosts as $host) {
+ yield [
+ 'search' => BpConfig::joinNodeName($host->name, 'Hoststatus'),
+ 'label' => $host->display_name,
+ 'class' => 'host'
+ ];
+ }
+ })());
+
+ $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest()));
+ }
+
+ public function icingadbServiceAction()
+ {
+ $excludes = Filter::none();
+ $forConfig = null;
+ if ($this->params->has('config')) {
+ $forConfig = $this->loadModifiedBpConfig();
+
+ if ($this->params->has('node')) {
+ $nodeName = $this->params->get('node');
+ $node = $forConfig->getBpNode($nodeName);
+
+ foreach ($node->getChildren() as $child) {
+ if ($child instanceof ServiceNode) {
+ $excludes->add(Filter::all(
+ Filter::equal('host.name', $child->getHostname()),
+ Filter::equal('service.name', $child->getServiceDescription())
+ ));
+ }
+ }
+ }
+ }
+
+ $suggestions = new TermSuggestions((function () use ($forConfig, $excludes, &$suggestions) {
+ foreach ($suggestions->getExcludeTerms() as $excludeTerm) {
+ [$hostName, $serviceName] = BpConfig::splitNodeName($excludeTerm);
+ if ($serviceName !== null && $serviceName !== 'Hoststatus') {
+ $excludes->add(Filter::all(
+ Filter::equal('host.name', $hostName),
+ Filter::equal('service.name', $serviceName)
+ ));
+ }
+ }
+
+ $services = Service::on($forConfig->getBackend())
+ ->columns(['host.name', 'host.display_name', 'service.name', 'service.display_name'])
+ ->limit(50);
+ IcingaDbObject::applyIcingaDbRestrictions($services);
+ $services->filter(Filter::all(
+ $excludes,
+ Filter::any(
+ Filter::like('host.name', $suggestions->getSearchTerm()),
+ Filter::equal('host.name', $suggestions->getOriginalSearchValue()),
+ Filter::like('host.display_name', $suggestions->getSearchTerm()),
+ Filter::equal('host.display_name', $suggestions->getOriginalSearchValue()),
+ Filter::like('service.name', $suggestions->getSearchTerm()),
+ Filter::equal('service.name', $suggestions->getOriginalSearchValue()),
+ Filter::like('service.display_name', $suggestions->getSearchTerm()),
+ Filter::equal('service.display_name', $suggestions->getOriginalSearchValue()),
+ Filter::like('service.customvar_flat.flatvalue', $suggestions->getSearchTerm()),
+ Filter::equal('service.customvar_flat.flatvalue', $suggestions->getOriginalSearchValue()),
+ Filter::like('servicegroup.name', $suggestions->getSearchTerm()),
+ Filter::equal('servicegroup.name', $suggestions->getOriginalSearchValue())
+ )
+ ));
+ foreach ($services as $service) {
+ yield [
+ 'class' => 'service',
+ 'search' => BpConfig::joinNodeName($service->host->name, $service->name),
+ 'label' => sprintf(
+ $this->translate('%s on %s', '<service> on <host>'),
+ $service->display_name,
+ $service->host->display_name
+ )
+ ];
+ }
+ })());
+
+ $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest()));
+ }
+
+ public function monitoringHostAction()
+ {
+ $excludes = LegacyFilter::matchAny();
+ $forConfig = null;
+ if ($this->params->has('config')) {
+ $forConfig = $this->loadModifiedBpConfig();
+
+ if ($this->params->has('node')) {
+ $nodeName = $this->params->get('node');
+ $node = $forConfig->getBpNode($nodeName);
+
+ foreach ($node->getChildren() as $child) {
+ if ($child instanceof HostNode) {
+ $excludes->addFilter(LegacyFilter::where('host_name', $child->getHostname()));
+ }
+ }
+ }
+ }
+
+ $suggestions = new TermSuggestions((function () use ($forConfig, $excludes, &$suggestions) {
+ foreach ($suggestions->getExcludeTerms() as $excludeTerm) {
+ [$hostName, $_] = BpConfig::splitNodeName($excludeTerm);
+ $excludes->addFilter(LegacyFilter::where('host_name', $hostName));
+ }
+
+ $hosts = (new HostStatus($forConfig->getBackend()->select(), ['host_name', 'host_display_name']))
+ ->limit(50)
+ ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects'))
+ ->applyFilter(LegacyFilter::matchAny(
+ LegacyFilter::where('host_name', $suggestions->getSearchTerm()),
+ LegacyFilter::where('host_display_name', $suggestions->getSearchTerm()),
+ LegacyFilter::where('host_address', $suggestions->getSearchTerm()),
+ LegacyFilter::where('host_address6', $suggestions->getSearchTerm()),
+ LegacyFilter::where('_host_%', $suggestions->getSearchTerm()),
+ // This also forces a group by on the query, needed anyway due to the custom var filter
+ // above, which may return multiple rows because of the wildcard in the name filter.
+ LegacyFilter::where('hostgroup_name', $suggestions->getSearchTerm()),
+ LegacyFilter::where('hostgroup_alias', $suggestions->getSearchTerm())
+ ));
+ if (! $excludes->isEmpty()) {
+ $hosts->applyFilter(LegacyFilter::not($excludes));
+ }
+
+ foreach ($hosts as $row) {
+ yield [
+ 'search' => BpConfig::joinNodeName($row->host_name, 'Hoststatus'),
+ 'label' => $row->host_display_name,
+ 'class' => 'host'
+ ];
+ }
+ })());
+
+ $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest()));
+ }
+
+ public function monitoringServiceAction()
+ {
+ $excludes = LegacyFilter::matchAny();
+ $forConfig = null;
+ if ($this->params->has('config')) {
+ $forConfig = $this->loadModifiedBpConfig();
+
+ if ($this->params->has('node')) {
+ $nodeName = $this->params->get('node');
+ $node = $forConfig->getBpNode($nodeName);
+
+ foreach ($node->getChildren() as $child) {
+ if ($child instanceof ServiceNode) {
+ $excludes->addFilter(LegacyFilter::matchAll(
+ LegacyFilter::where('host_name', $child->getHostname()),
+ LegacyFilter::where('service_description', $child->getServiceDescription())
+ ));
+ }
+ }
+ }
+ }
+
+ $suggestions = new TermSuggestions((function () use ($forConfig, $excludes, &$suggestions) {
+ foreach ($suggestions->getExcludeTerms() as $excludeTerm) {
+ [$hostName, $serviceName] = BpConfig::splitNodeName($excludeTerm);
+ if ($serviceName !== null && $serviceName !== 'Hoststatus') {
+ $excludes->addFilter(LegacyFilter::matchAll(
+ LegacyFilter::where('host_name', $hostName),
+ LegacyFilter::where('service_description', $serviceName)
+ ));
+ }
+ }
+
+ $services = (new ServiceStatus($forConfig->getBackend()->select(), [
+ 'host_name',
+ 'host_display_name',
+ 'service_description',
+ 'service_display_name'
+ ]))
+ ->limit(50)
+ ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects'))
+ ->applyFilter(LegacyFilter::matchAny(
+ LegacyFilter::where('host_name', $suggestions->getSearchTerm()),
+ LegacyFilter::where('host_display_name', $suggestions->getSearchTerm()),
+ LegacyFilter::where('service_description', $suggestions->getSearchTerm()),
+ LegacyFilter::where('service_display_name', $suggestions->getSearchTerm()),
+ LegacyFilter::where('_service_%', $suggestions->getSearchTerm()),
+ // This also forces a group by on the query, needed anyway due to the custom var filter
+ // above, which may return multiple rows because of the wildcard in the name filter.
+ LegacyFilter::where('servicegroup_name', $suggestions->getSearchTerm()),
+ LegacyFilter::where('servicegroup_alias', $suggestions->getSearchTerm())
+ ));
+ if (! $excludes->isEmpty()) {
+ $services->applyFilter(LegacyFilter::not($excludes));
+ }
+
+ foreach ($services as $row) {
+ yield [
+ 'class' => 'service',
+ 'search' => BpConfig::joinNodeName($row->host_name, $row->service_description),
+ 'label' => sprintf(
+ $this->translate('%s on %s', '<service> on <host>'),
+ $row->service_display_name,
+ $row->host_display_name
+ )
+ ];
+ }
+ })());
+
+ $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest()));
+ }
+}
diff --git a/application/forms/AddNodeForm.php b/application/forms/AddNodeForm.php
new file mode 100644
index 0000000..3840d8a
--- /dev/null
+++ b/application/forms/AddNodeForm.php
@@ -0,0 +1,412 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Exception;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Common\Sort;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Storage\Storage;
+use Icinga\Module\Businessprocess\Web\Form\Element\IplStateOverrides;
+use Icinga\Module\Businessprocess\Web\Form\Validator\HostServiceTermValidator;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Web\Session\SessionNamespace;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Str;
+use ipl\Web\Compat\CompatForm;
+use ipl\Web\FormElement\TermInput;
+use ipl\Web\Url;
+
+class AddNodeForm extends CompatForm
+{
+ use Sort;
+ use Translation;
+
+ /** @var Storage */
+ protected $storage;
+
+ /** @var ?BpConfig */
+ protected $bp;
+
+ /** @var ?BpNode */
+ protected $parent;
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ /**
+ * Set the storage to use
+ *
+ * @param Storage $storage
+ *
+ * @return $this
+ */
+ public function setStorage(Storage $storage): self
+ {
+ $this->storage = $storage;
+
+ return $this;
+ }
+
+ /**
+ * Set the affected configuration
+ *
+ * @param BpConfig $bp
+ *
+ * @return $this
+ */
+ public function setProcess(BpConfig $bp): self
+ {
+ $this->bp = $bp;
+
+ return $this;
+ }
+
+ /**
+ * Set the affected sub-process
+ *
+ * @param ?BpNode $node
+ *
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null): self
+ {
+ $this->parent = $node;
+
+ return $this;
+ }
+
+ /**
+ * Set the user's session
+ *
+ * @param SessionNamespace $session
+ *
+ * @return $this
+ */
+ public function setSession(SessionNamespace $session): self
+ {
+ $this->session = $session;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ if ($this->parent !== null) {
+ $title = sprintf($this->translate('Add a node to %s'), $this->parent->getAlias());
+ $nodeTypes = [
+ 'host' => $this->translate('Host'),
+ 'service' => $this->translate('Service'),
+ 'process' => $this->translate('Existing Process'),
+ 'new-process' => $this->translate('New Process')
+ ];
+ } else {
+ $title = $this->translate('Add a new root node');
+ if (! $this->bp->isEmpty()) {
+ $nodeTypes = [
+ 'process' => $this->translate('Existing Process'),
+ 'new-process' => $this->translate('New Process')
+ ];
+ } else {
+ $nodeTypes = [];
+ }
+ }
+
+ $this->addHtml(new HtmlElement('h2', null, Text::create($title)));
+
+ if (! empty($nodeTypes)) {
+ $this->addElement('select', 'node_type', [
+ 'label' => $this->translate('Node type'),
+ 'options' => array_merge(
+ ['' => ' - ' . $this->translate('Please choose') . ' - '],
+ $nodeTypes
+ ),
+ 'disabledOptions' => [''],
+ 'class' => 'autosubmit',
+ 'required' => true,
+ 'ignore' => true
+ ]);
+
+ $nodeType = $this->getPopulatedValue('node_type');
+ } else {
+ $nodeType = 'new-process';
+ }
+
+ if ($nodeType === 'new-process') {
+ $this->assembleNewProcessElements();
+ } elseif ($nodeType === 'process') {
+ $this->assembleExistingProcessElements();
+ } elseif ($nodeType === 'host') {
+ $this->assembleHostElements();
+ } elseif ($nodeType === 'service') {
+ $this->assembleServiceElements();
+ }
+
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('Add Process')
+ ]);
+ }
+
+ protected function assembleNewProcessElements(): void
+ {
+ $this->addElement('text', 'name', [
+ 'required' => true,
+ 'ignore' => true,
+ 'label' => $this->translate('ID'),
+ 'description' => $this->translate('This is the unique identifier of this process'),
+ 'validators' => [
+ 'callback' => function ($value, $validator) {
+ if ($this->parent !== null ? $this->parent->hasChild($value) : $this->bp->hasRootNode($value)) {
+ $validator->addMessage(
+ sprintf($this->translate('%s is already defined in this process'), $value)
+ );
+
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]);
+
+ $this->addElement('text', 'alias', [
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'Usually this name will be shown for this node. Equals ID if not given'
+ ),
+ ]);
+
+ $this->addElement('select', 'operator', [
+ 'required' => true,
+ 'label' => $this->translate('Operator'),
+ 'multiOptions' => Node::getOperators()
+ ]);
+
+ $display = 1;
+ if (! $this->bp->isEmpty() && $this->bp->getMetadata()->isManuallyOrdered()) {
+ $rootNodes = self::applyManualSorting($this->bp->getRootNodes());
+ $display = end($rootNodes)->getDisplay() + 1;
+ }
+ $this->addElement('select', 'display', [
+ 'required' => true,
+ 'label' => $this->translate('Visualization'),
+ 'description' => $this->translate('Where to show this process'),
+ 'value' => $this->parent !== null ? '0' : "$display",
+ 'multiOptions' => [
+ "$display" => $this->translate('Toplevel Process'),
+ '0' => $this->translate('Subprocess only'),
+ ]
+ ]);
+
+ $this->addElement('text', 'infoUrl', [
+ 'label' => $this->translate('Info URL'),
+ 'description' => $this->translate('URL pointing to more information about this node')
+ ]);
+ }
+
+ protected function assembleExistingProcessElements(): void
+ {
+ $termValidator = function (array $terms) {
+ foreach ($terms as $term) {
+ /** @var TermInput\ValidatedTerm $term */
+ $nodeName = $term->getSearchValue();
+ if ($nodeName[0] === '@') {
+ if ($this->parent === null) {
+ $term->setMessage($this->translate('Imported nodes cannot be used as root nodes'));
+ } elseif (strpos($nodeName, ':') === false) {
+ $term->setMessage($this->translate('Missing node name'));
+ } else {
+ [$config, $nodeName] = Str::trimSplit(substr($nodeName, 1), ':', 2);
+ if (! $this->storage->hasProcess($config)) {
+ $term->setMessage($this->translate('Config does not exist or access has been denied'));
+ } else {
+ try {
+ $bp = $this->storage->loadProcess($config);
+ } catch (Exception $e) {
+ $term->setMessage(
+ sprintf($this->translate('Cannot load config: %s'), $e->getMessage())
+ );
+ }
+
+ if (isset($bp)) {
+ if (! $bp->hasNode($nodeName)) {
+ $term->setMessage($this->translate('No node with this name found in config'));
+ } else {
+ $term->setLabel($bp->getNode($nodeName)->getAlias());
+ }
+ }
+ }
+ }
+ } elseif (! $this->bp->hasNode($nodeName)) {
+ $term->setMessage($this->translate('No node with this name found in config'));
+ } else {
+ $term->setLabel($this->bp->getNode($nodeName)->getAlias());
+ }
+
+ if ($this->parent !== null && $this->parent->hasChild($term->getSearchValue())) {
+ $term->setMessage($this->translate('Already defined in this process'));
+ }
+
+ if ($this->parent !== null && $term->getSearchValue() === $this->parent->getName()) {
+ $term->setMessage($this->translate('Results in a parent/child loop'));
+ }
+ }
+ };
+
+ $this->addElement(
+ (new TermInput('children'))
+ ->setRequired()
+ ->setVerticalTermDirection()
+ ->setLabel($this->translate('Process Nodes'))
+ ->setSuggestionUrl(Url::fromPath('businessprocess/suggestions/process', [
+ 'node' => isset($this->parent) ? $this->parent->getName() : null,
+ 'config' => $this->bp->getName(),
+ 'showCompact' => true,
+ '_disableLayout' => true
+ ]))
+ ->on(TermInput::ON_ENRICH, $termValidator)
+ ->on(TermInput::ON_ADD, $termValidator)
+ ->on(TermInput::ON_PASTE, $termValidator)
+ ->on(TermInput::ON_SAVE, $termValidator)
+ );
+ }
+
+ protected function assembleHostElements(): void
+ {
+ if ($this->bp->getBackend() instanceof MonitoringBackend) {
+ $suggestionsPath = 'businessprocess/suggestions/monitoring-host';
+ } else {
+ $suggestionsPath = 'businessprocess/suggestions/icingadb-host';
+ }
+
+ $this->addElement($this->createChildrenElementForObjects(
+ $this->translate('Hosts'),
+ $suggestionsPath
+ ));
+
+ $this->addElement('checkbox', 'host_override', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Override Host State')
+ ]);
+ if ($this->getPopulatedValue('host_override') === 'y') {
+ $this->addElement(new IplStateOverrides('stateOverrides', [
+ 'label' => $this->translate('State Overrides'),
+ 'options' => [
+ 0 => $this->translate('UP'),
+ 1 => $this->translate('DOWN'),
+ 99 => $this->translate('PENDING')
+ ]
+ ]));
+ }
+ }
+
+ protected function assembleServiceElements(): void
+ {
+ if ($this->bp->getBackend() instanceof MonitoringBackend) {
+ $suggestionsPath = 'businessprocess/suggestions/monitoring-service';
+ } else {
+ $suggestionsPath = 'businessprocess/suggestions/icingadb-service';
+ }
+
+ $this->addElement($this->createChildrenElementForObjects(
+ $this->translate('Services'),
+ $suggestionsPath
+ ));
+
+ $this->addElement('checkbox', 'service_override', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Override Service State')
+ ]);
+ if ($this->getPopulatedValue('service_override') === 'y') {
+ $this->addElement(new IplStateOverrides('stateOverrides', [
+ 'label' => $this->translate('State Overrides'),
+ 'options' => [
+ 0 => $this->translate('OK'),
+ 1 => $this->translate('WARNING'),
+ 2 => $this->translate('CRITICAL'),
+ 3 => $this->translate('UNKNOWN'),
+ 99 => $this->translate('PENDING'),
+ ]
+ ]));
+ }
+ }
+
+ protected function createChildrenElementForObjects(string $label, string $suggestionsPath): TermInput
+ {
+ $termValidator = function (array $terms) {
+ (new HostServiceTermValidator())
+ ->setParent($this->parent)
+ ->isValid($terms);
+ };
+
+ return (new TermInput('children'))
+ ->setRequired()
+ ->setLabel($label)
+ ->setVerticalTermDirection()
+ ->setSuggestionUrl(Url::fromPath($suggestionsPath, [
+ 'node' => isset($this->parent) ? $this->parent->getName() : null,
+ 'config' => $this->bp->getName(),
+ 'showCompact' => true,
+ '_disableLayout' => true
+ ]))
+ ->on(TermInput::ON_ENRICH, $termValidator)
+ ->on(TermInput::ON_ADD, $termValidator)
+ ->on(TermInput::ON_PASTE, $termValidator)
+ ->on(TermInput::ON_SAVE, $termValidator);
+ }
+
+ protected function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $nodeType = $this->getValue('node_type');
+ if (! $nodeType || $nodeType === 'new-process') {
+ $properties = $this->getValues();
+ if (! $properties['alias']) {
+ unset($properties['alias']);
+ }
+
+ if ($this->parent !== null) {
+ $properties['parentName'] = $this->parent->getName();
+ }
+
+ $changes->createNode(BpConfig::escapeName($this->getValue('name')), $properties);
+ } else {
+ /** @var TermInput $term */
+ $term = $this->getElement('children');
+ $children = array_unique(array_map(function ($term) {
+ return $term->getSearchValue();
+ }, $term->getTerms()));
+
+ if ($nodeType === 'host' || $nodeType === 'service') {
+ $stateOverrides = $this->getValue('stateOverrides');
+ if (! empty($stateOverrides)) {
+ $childOverrides = [];
+ foreach ($children as $nodeName) {
+ $childOverrides[$nodeName] = $stateOverrides;
+ }
+
+ $changes->modifyNode($this->parent, [
+ 'stateOverrides' => array_merge($this->parent->getStateOverrides(), $childOverrides)
+ ]);
+ }
+ }
+
+ if ($this->parent !== null) {
+ $changes->addChildrenToNode($children, $this->parent);
+ } else {
+ foreach ($children as $nodeName) {
+ $changes->copyNode($nodeName);
+ }
+ }
+ }
+
+ unset($changes);
+ }
+}
diff --git a/application/forms/BpConfigForm.php b/application/forms/BpConfigForm.php
new file mode 100644
index 0000000..8a0bc95
--- /dev/null
+++ b/application/forms/BpConfigForm.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+
+class BpConfigForm extends BpConfigBaseForm
+{
+ protected $deleteButtonName;
+
+ public function setup()
+ {
+ $this->addElement('text', 'name', array(
+ 'label' => $this->translate('ID'),
+ 'required' => true,
+ 'validators' => array(
+ array(
+ 'validator' => 'StringLength',
+ 'options' => array(
+ 'min' => 2,
+ 'max' => 40
+ )
+ ),
+ [
+ 'validator' => 'Regex',
+ 'options' => [
+ 'pattern' => '/^[a-zA-Z0-9](?:[\w\h._-]*)?\w$/',
+ 'messages' => [
+ 'regexNotMatch' => $this->translate(
+ 'Id must only consist of alphanumeric characters.'
+ . ' Underscore at the beginning and space, dot and hyphen at the beginning'
+ . ' and end are not allowed.'
+ )
+ ]
+ ]
+ ]
+ ),
+ 'description' => $this->translate(
+ 'This is the unique identifier of this process'
+ ),
+ ));
+
+ $this->addElement('text', 'Title', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'Usually this name will be shown for this process. Equals ID'
+ . ' if not given'
+ ),
+ ));
+
+ $this->addElement('textarea', 'Description', array(
+ 'label' => $this->translate('Description'),
+ 'description' => $this->translate(
+ 'A slightly more detailed description for this process, about 100-150 characters long'
+ ),
+ 'rows' => 4,
+ ));
+
+ if (! empty($this->listAvailableBackends())) {
+ $this->addElement('select', 'Backend', array(
+ 'label' => $this->translate('Backend'),
+ 'description' => $this->translate(
+ 'Icinga Web Monitoring Backend where current object states for'
+ . ' this process should be retrieved from'
+ ),
+ 'multiOptions' => array(
+ null => $this->translate('Use the configured default backend'),
+ ) + $this->listAvailableBackends()
+ ));
+ }
+
+ $this->addElement('select', 'Statetype', array(
+ 'label' => $this->translate('State Type'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Whether this process should be based on Icinga hard or soft states'
+ ),
+ 'multiOptions' => array(
+ 'soft' => $this->translate('Use SOFT states'),
+ 'hard' => $this->translate('Use HARD states'),
+ )
+ ));
+
+ $this->addElement('select', 'AddToMenu', array(
+ 'label' => $this->translate('Add to menu'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Whether this process should be linked in the main Icinga Web 2 menu'
+ ),
+ 'multiOptions' => array(
+ 'yes' => $this->translate('Yes'),
+ 'no' => $this->translate('No'),
+ )
+ ));
+
+ $this->addElement('text', 'AllowedUsers', array(
+ 'label' => $this->translate('Allowed Users'),
+ 'description' => $this->translate(
+ 'Allowed Users (comma-separated)'
+ ),
+ ));
+
+ $this->addElement('text', 'AllowedGroups', array(
+ 'label' => $this->translate('Allowed Groups'),
+ 'description' => $this->translate(
+ 'Allowed Groups (comma-separated)'
+ ),
+ ));
+
+ $this->addElement('text', 'AllowedRoles', array(
+ 'label' => $this->translate('Allowed Roles'),
+ 'description' => $this->translate(
+ 'Allowed Roles (comma-separated)'
+ ),
+ ));
+
+ if ($this->bp === null) {
+ $this->setSubmitLabel(
+ $this->translate('Add')
+ );
+ } else {
+ $config = $this->bp;
+
+ $meta = $config->getMetadata();
+ foreach ($meta->getProperties() as $k => $v) {
+ if ($el = $this->getElement($k)) {
+ $el->setValue($v);
+ }
+ }
+ $this->getElement('name')
+ ->setValue($config->getName())
+ ->setAttrib('readonly', true);
+
+ $this->setSubmitLabel(
+ $this->translate('Store')
+ );
+
+ $label = $this->translate('Delete');
+ $el = $this->createElement('submit', $label, array(
+ 'data-base-target' => '_main'
+ ))->setLabel($label)->setDecorators(array('ViewHelper'));
+ $this->deleteButtonName = $el->getName();
+ $this->addElement($el);
+ }
+ }
+
+ protected function onSetup()
+ {
+ $this->getElement($this->getSubmitLabel())->setAttrib('data-base-target', '_main');
+ }
+
+ protected function onRequest()
+ {
+ $name = $this->getValue('name');
+
+ if ($this->shouldBeDeleted()) {
+ if ($this->bp->isReferenced()) {
+ $this->addError(sprintf(
+ $this->translate('Process "%s" cannot be deleted as it has been referenced in other processes'),
+ $name
+ ));
+ } else {
+ $this->bp->clearAppliedChanges();
+ $this->storage->deleteProcess($name);
+ $this->setSuccessUrl('businessprocess');
+ $this->redirectOnSuccess(sprintf('Process %s has been deleted', $name));
+ }
+ }
+ }
+
+ public function onSuccess()
+ {
+ $name = $this->getValue('name');
+
+ if ($this->bp === null) {
+ if ($this->storage->hasProcess($name)) {
+ $this->addError(sprintf(
+ $this->translate('A process named "%s" already exists'),
+ $name
+ ));
+
+ return;
+ }
+
+ // New config
+ $config = new BpConfig();
+ $config->setName($name);
+
+ if (! $this->prepareMetadata($config)) {
+ return;
+ }
+
+ $this->setSuccessUrl(
+ $this->getSuccessUrl()->setParams(
+ array('config' => $name, 'unlocked' => true)
+ )
+ );
+ $this->setSuccessMessage(sprintf('Process %s has been created', $name));
+ } else {
+ $config = $this->bp;
+ $this->setSuccessMessage(sprintf('Process %s has been stored', $name));
+ }
+ $meta = $config->getMetadata();
+ foreach ($this->getValues() as $key => $value) {
+ if (! in_array($key, ['Title', 'Description', 'Backend'], true)
+ && ($value === null || $value === '')) {
+ continue;
+ }
+
+ if ($meta->hasKey($key)) {
+ $meta->set($key, $value);
+ }
+ }
+
+ $this->storage->storeProcess($config);
+ $config->clearAppliedChanges();
+ parent::onSuccess();
+ }
+
+ public function hasDeleteButton()
+ {
+ return $this->deleteButtonName !== null;
+ }
+
+ public function shouldBeDeleted()
+ {
+ if (! $this->hasDeleteButton()) {
+ return false;
+ }
+
+ $name = $this->deleteButtonName;
+ return $this->getSentValue($name) === $this->getElement($name)->getLabel();
+ }
+}
diff --git a/application/forms/BpUploadForm.php b/application/forms/BpUploadForm.php
new file mode 100644
index 0000000..a746740
--- /dev/null
+++ b/application/forms/BpUploadForm.php
@@ -0,0 +1,207 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Exception;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Storage\LegacyConfigParser;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+use Icinga\Web\Notification;
+
+class BpUploadForm extends BpConfigBaseForm
+{
+ protected $node;
+
+ protected $objectList = array();
+
+ protected $processList = array();
+
+ protected $deleteButtonName;
+
+ private $sourceCode;
+
+ /** @var BpConfig */
+ private $uploadedConfig;
+
+ public function setup()
+ {
+ $this->showUpload();
+ if ($this->hasSource()) {
+ $this->showDetails();
+ }
+ }
+
+ protected function showDetails()
+ {
+ $this->addElement('text', 'name', array(
+ 'label' => $this->translate('Name'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'This is the unique identifier of this process'
+ ),
+ 'validators' => array(
+ array(
+ 'validator' => 'StringLength',
+ 'options' => array(
+ 'min' => 2,
+ 'max' => 40
+ )
+ ),
+ [
+ 'validator' => 'Regex',
+ 'options' => [
+ 'pattern' => '/^[a-zA-Z0-9](?:[\w\h._-]*)?\w$/',
+ 'messages' => [
+ 'regexNotMatch' => $this->translate(
+ 'Id must only consist of alphanumeric characters.'
+ . ' Underscore at the beginning and space, dot and hyphen at the beginning'
+ . ' and end are not allowed.'
+ )
+ ]
+ ]
+ ]
+ ),
+ ));
+
+ $this->addElement('textarea', 'source', array(
+ 'label' => $this->translate('Source'),
+ 'description' => $this->translate(
+ 'Business process source code'
+ ),
+ 'value' => $this->sourceCode,
+ 'class' => 'preformatted smaller',
+ 'rows' => 7,
+ ));
+
+ $this->getUploadedConfig();
+
+ $this->setSubmitLabel(
+ $this->translate('Store')
+ );
+ }
+
+ public function getUploadedConfig()
+ {
+ if ($this->uploadedConfig === null) {
+ $this->uploadedConfig = $this->parseSubmittedSourceCode();
+ }
+
+ return $this->uploadedConfig;
+ }
+
+ protected function parseSubmittedSourceCode()
+ {
+ $code = $this->getSentValue('source');
+ $name = $this->getSentValue('name', '<new config>');
+ if (empty($code)) {
+ $code = $this->sourceCode;
+ }
+
+ try {
+ $config = LegacyConfigParser::parseString($name, $code);
+
+ if ($config->hasErrors()) {
+ foreach ($config->getErrors() as $error) {
+ $this->addError($error);
+ }
+ }
+ } catch (Exception $e) {
+ $this->addError($e->getMessage());
+ return null;
+ }
+
+ return $config;
+ }
+
+ protected function hasSource()
+ {
+ if ($this->hasBeenSent() && $source = $this->getSentValue('source')) {
+ $this->sourceCode = $source;
+ } else {
+ $this->processUploadedSource();
+ }
+
+ if (empty($this->sourceCode)) {
+ return false;
+ } else {
+ $this->removeElement('uploaded_file');
+ return true;
+ }
+ }
+
+ protected function showUpload()
+ {
+ $this->setAttrib('enctype', 'multipart/form-data');
+
+ $this->addElement('file', 'uploaded_file', array(
+ 'label' => $this->translate('File'),
+ 'destination' => $this->getTempDir(),
+ 'required' => true,
+ ));
+
+ /** @var \Zend_Form_Element_File $el */
+ $el = $this->getElement('uploaded_file');
+ $el->setValueDisabled(true);
+
+ $this->setSubmitLabel(
+ $this->translate('Next')
+ );
+ }
+
+ protected function getTempDir()
+ {
+ return sys_get_temp_dir();
+ }
+
+ protected function processUploadedSource()
+ {
+ /** @var ?\Zend_Form_Element_File $el */
+ $el = $this->getElement('uploaded_file');
+
+ if ($el && $this->hasBeenSent()) {
+ $tmpdir = $this->getTempDir();
+ $tmpfile = tempnam($tmpdir, 'bpupload_');
+
+ // TODO: race condition, try to do this without unlinking here
+ unlink($tmpfile);
+
+ $el->addFilter('Rename', $tmpfile);
+ if ($el->receive()) {
+ $this->sourceCode = file_get_contents($tmpfile);
+ unlink($tmpfile);
+ } else {
+ foreach ($el->file->getMessages() as $error) {
+ $this->addError($error);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $config = $this->getUploadedConfig();
+ $name = $config->getName();
+
+ if ($this->storage->hasProcess($name)) {
+ $this->addError(sprintf(
+ $this->translate('A process named "%s" already exists'),
+ $name
+ ));
+
+ return;
+ }
+
+ if (! $this->prepareMetadata($config)) {
+ return;
+ }
+
+ $this->storage->storeProcess($config);
+ Notification::success(sprintf('Process %s has been stored', $name));
+
+ $this->getSuccessUrl()->setParam('config', $name);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/CleanupNodeForm.php b/application/forms/CleanupNodeForm.php
new file mode 100644
index 0000000..c6e5398
--- /dev/null
+++ b/application/forms/CleanupNodeForm.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Web\Session\SessionNamespace;
+use ipl\Html\Html;
+use ipl\Sql\Connection as IcingaDbConnection;
+
+class CleanupNodeForm extends BpConfigBaseForm
+{
+ /** @var MonitoringBackend|IcingaDbConnection */
+ protected $backend;
+
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ public function setup()
+ {
+ $this->addHtml(Html::tag('h2', $this->translate('Cleanup missing nodes')));
+
+ $this->addElement('checkbox', 'cleanup_all', [
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Cleanup all missing nodes'),
+ 'description' => $this->translate('Remove all missing nodes from config')
+ ]);
+
+ if ($this->getSentValue('cleanup_all') !== '1') {
+ $this->addElement('multiselect', 'nodes', [
+ 'label' => $this->translate('Select nodes to cleanup'),
+ 'required' => true,
+ 'size' => 8,
+ 'multiOptions' => $this->bp->getMissingChildren()
+ ]);
+ }
+ }
+
+ public function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $nodesToCleanup = $this->getValue('cleanup_all') === '1'
+ ? array_keys($this->bp->getMissingChildren())
+ : $this->getValue('nodes');
+
+ foreach ($nodesToCleanup as $nodeName) {
+ $node = $this->bp->getNode($nodeName);
+ $changes->deleteNode($node);
+ }
+
+ unset($changes);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/DeleteNodeForm.php b/application/forms/DeleteNodeForm.php
new file mode 100644
index 0000000..dba0710
--- /dev/null
+++ b/application/forms/DeleteNodeForm.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+use Icinga\Web\View;
+
+class DeleteNodeForm extends BpConfigBaseForm
+{
+ /** @var Node */
+ protected $node;
+
+ /** @var ?BpNode */
+ protected $parentNode;
+
+ public function setup()
+ {
+ $node = $this->node;
+ $nodeName = $node->getAlias() ?? $node->getName();
+
+ /** @var View $view */
+ $view = $this->getView();
+ $this->addHtml(
+ '<h2>' . $view->escape(
+ sprintf($this->translate('Delete "%s"'), $nodeName)
+ ) . '</h2>'
+ );
+
+ $biLink = $view->qlink(
+ $nodeName,
+ 'businessprocess/node/impact',
+ array('name' => $node->getName()),
+ array('data-base-target' => '_next')
+ );
+ $this->addHtml(
+ '<p>' . sprintf(
+ $view->escape(
+ $this->translate('Unsure? Show business impact of "%s"')
+ ),
+ $biLink
+ ) . '</p>'
+ );
+
+ if ($this->parentNode) {
+ $yesMsg = sprintf(
+ $this->translate('Delete from %s'),
+ $this->parentNode->getAlias()
+ );
+ } else {
+ $yesMsg = sprintf(
+ $this->translate('Delete root node "%s"'),
+ $nodeName
+ );
+ }
+
+ $this->addElement('select', 'confirm', array(
+ 'label' => $this->translate('Are you sure?'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Do you really want to delete this node?'
+ ),
+ 'multiOptions' => $this->optionalEnum(array(
+ 'no' => $this->translate('No'),
+ 'yes' => $yesMsg,
+ 'all' => sprintf($this->translate('Delete all occurrences of %s'), $nodeName),
+ ))
+ ));
+ }
+
+ /**
+ * @param Node $node
+ * @return $this
+ */
+ public function setNode(Node $node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ /**
+ * @param BpNode|null $node
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null)
+ {
+ $this->parentNode = $node;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $confirm = $this->getValue('confirm');
+ switch ($confirm) {
+ case 'yes':
+ $changes->deleteNode($this->node, $this->parentNode === null ? null : $this->parentNode->getName());
+ break;
+ case 'all':
+ $changes->deleteNode($this->node);
+ break;
+ case 'no':
+ $this->setSuccessMessage($this->translate('Well, maybe next time'));
+ }
+
+ switch ($confirm) {
+ case 'yes':
+ case 'all':
+ if ($this->successUrl === null) {
+ $this->successUrl = clone $this->getRequest()->getUrl();
+ }
+
+ $this->successUrl->getParams()->remove(array('action', 'deletenode'));
+ }
+
+ // Trigger session desctruction to make sure it get's stored.
+ // TODO: figure out why this is necessary, might be an unclean shutdown on redirect
+ unset($changes);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/EditNodeForm.php b/application/forms/EditNodeForm.php
new file mode 100644
index 0000000..bd1592b
--- /dev/null
+++ b/application/forms/EditNodeForm.php
@@ -0,0 +1,315 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\ServiceNode;
+use Icinga\Module\Businessprocess\Web\Form\Element\IplStateOverrides;
+use Icinga\Module\Businessprocess\Web\Form\Validator\HostServiceTermValidator;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+use Icinga\Web\Session\SessionNamespace;
+use ipl\Html\Attributes;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\ValidHtml;
+use ipl\I18n\Translation;
+use ipl\Web\Compat\CompatForm;
+use ipl\Web\FormElement\TermInput\ValidatedTerm;
+use ipl\Web\Url;
+
+class EditNodeForm extends CompatForm
+{
+ use Translation;
+
+ /** @var ?BpConfig */
+ protected $bp;
+
+ /** @var ?Node */
+ protected $node;
+
+ /** @var ?BpNode */
+ protected $parent;
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ /**
+ * Set the affected configuration
+ *
+ * @param BpConfig $bp
+ *
+ * @return $this
+ */
+ public function setProcess(BpConfig $bp): self
+ {
+ $this->bp = $bp;
+
+ return $this;
+ }
+
+ /**
+ * Set the affected node
+ *
+ * @param Node $node
+ *
+ * @return $this
+ */
+ public function setNode(Node $node): self
+ {
+ $this->node = $node;
+
+ $this->populate([
+ 'node-search' => $node->getName(),
+ 'node-label' => $node->getAlias()
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Set the affected sub-process
+ *
+ * @param ?BpNode $node
+ *
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null): self
+ {
+ $this->parent = $node;
+
+ if ($this->node !== null) {
+ $stateOverrides = $this->parent->getStateOverrides($this->node->getName());
+ if (! empty($stateOverrides)) {
+ $this->populate([
+ 'overrideStates' => 'y',
+ 'stateOverrides' => $stateOverrides
+ ]);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set the user's session
+ *
+ * @param SessionNamespace $session
+ *
+ * @return $this
+ */
+ public function setSession(SessionNamespace $session): self
+ {
+ $this->session = $session;
+
+ return $this;
+ }
+
+ /**
+ * Identify and return the node the user has chosen
+ *
+ * @return Node
+ */
+ protected function identifyChosenNode(): Node
+ {
+ $userInput = $this->getPopulatedValue('node');
+ $nodeName = $this->getPopulatedValue('node-search');
+ $nodeLabel = $this->getPopulatedValue('node-label');
+
+ if ($nodeName && $userInput === $nodeLabel) {
+ // User accepted a suggestion and didn't change it manually
+ $node = $this->bp->getNode($nodeName);
+ } elseif ($userInput && (! $nodeLabel || $userInput !== $nodeLabel)) {
+ // User didn't choose a suggestion or changed it manually
+ $node = $this->bp->getNode(BpConfig::joinNodeName($userInput, 'Hoststatus'));
+ } else {
+ // If the search and user input are both empty, it can only be the initial value
+ $node = $this->node;
+ }
+
+ return $node;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(new HtmlElement('h2', null, FormattedString::create(
+ $this->translate('Modify "%s"'),
+ $this->node->getAlias() ?? $this->node->getName()
+ )));
+
+ if ($this->node instanceof ServiceNode) {
+ $this->assembleServiceElements();
+ } else {
+ $this->assembleHostElements();
+ }
+
+ $this->addElement('submit', 'btn_submit', [
+ 'label' => $this->translate('Save Changes')
+ ]);
+ }
+
+ protected function assembleServiceElements(): void
+ {
+ if ($this->bp->getBackend() instanceof MonitoringBackend) {
+ $suggestionsPath = 'businessprocess/suggestions/monitoring-service';
+ } else {
+ $suggestionsPath = 'businessprocess/suggestions/icingadb-service';
+ }
+
+ $node = $this->identifyChosenNode();
+
+ $this->addHtml($this->createSearchInput(
+ $this->translate('Service'),
+ $node->getAlias() ?? $node->getName(),
+ $suggestionsPath
+ ));
+
+ $this->addElement('checkbox', 'overrideStates', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Override Service State')
+ ]);
+ if ($this->getPopulatedValue('overrideStates') === 'y') {
+ $this->addElement(new IplStateOverrides('stateOverrides', [
+ 'label' => $this->translate('State Overrides'),
+ 'options' => [
+ 0 => $this->translate('OK'),
+ 1 => $this->translate('WARNING'),
+ 2 => $this->translate('CRITICAL'),
+ 3 => $this->translate('UNKNOWN'),
+ 99 => $this->translate('PENDING'),
+ ]
+ ]));
+ }
+ }
+
+ protected function assembleHostElements(): void
+ {
+ if ($this->bp->getBackend() instanceof MonitoringBackend) {
+ $suggestionsPath = 'businessprocess/suggestions/monitoring-host';
+ } else {
+ $suggestionsPath = 'businessprocess/suggestions/icingadb-host';
+ }
+
+ $node = $this->identifyChosenNode();
+
+ $this->addHtml($this->createSearchInput(
+ $this->translate('Host'),
+ $node->getAlias() ?? $node->getName(),
+ $suggestionsPath
+ ));
+
+ $this->addElement('checkbox', 'overrideStates', [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'label' => $this->translate('Override Host State')
+ ]);
+ if ($this->getPopulatedValue('overrideStates') === 'y') {
+ $this->addElement(new IplStateOverrides('stateOverrides', [
+ 'label' => $this->translate('State Overrides'),
+ 'options' => [
+ 0 => $this->translate('UP'),
+ 1 => $this->translate('DOWN'),
+ 99 => $this->translate('PENDING')
+ ]
+ ]));
+ }
+ }
+
+ protected function createSearchInput(string $label, string $value, string $suggestionsPath): ValidHtml
+ {
+ $userInput = $this->createElement('text', 'node', [
+ 'ignore' => true,
+ 'required' => true,
+ 'autocomplete' => 'off',
+ 'label' => $label,
+ 'value' => $value,
+ 'data-enrichment-type' => 'completion',
+ 'data-term-suggestions' => '#node-suggestions',
+ 'data-suggest-url' => Url::fromPath($suggestionsPath, [
+ 'node' => isset($this->parent) ? $this->parent->getName() : null,
+ 'config' => $this->bp->getName(),
+ 'showCompact' => true,
+ '_disableLayout' => true
+ ]),
+ 'validators' => ['callback' => function ($_, $validator) {
+ $newName = $this->identifyChosenNode()->getName();
+ if ($newName === $this->node->getName()) {
+ return true;
+ }
+
+ $term = new ValidatedTerm($newName);
+
+ (new HostServiceTermValidator())
+ ->setParent($this->parent)
+ ->isValid($term);
+
+ if (! $term->isValid()) {
+ $validator->addMessage($term->getMessage());
+ return false;
+ }
+
+ return true;
+ }]
+ ]);
+
+ $fieldset = new HtmlElement('fieldset');
+
+ $searchInput = $this->createElement('hidden', 'node-search', ['ignore' => true]);
+ $this->registerElement($searchInput);
+ $fieldset->addHtml($searchInput);
+
+ $labelInput = $this->createElement('hidden', 'node-label', ['ignore' => true]);
+ $this->registerElement($labelInput);
+ $fieldset->addHtml($labelInput);
+
+ $this->registerElement($userInput);
+ $this->decorate($userInput);
+
+ $fieldset->addHtml(
+ $userInput,
+ new HtmlElement('div', Attributes::create([
+ 'id' => 'node-suggestions',
+ 'class' => 'search-suggestions'
+ ]))
+ );
+
+ return $fieldset;
+ }
+
+ protected function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $children = $this->parent->getChildNames();
+ $previousPos = array_search($this->node->getName(), $children, true);
+ $node = $this->identifyChosenNode();
+ $nodeName = $node->getName();
+
+ $changes->deleteNode($this->node, $this->parent->getName());
+ $changes->addChildrenToNode([$nodeName], $this->parent);
+
+ $stateOverrides = $this->getValue('stateOverrides');
+ if (! empty($stateOverrides)) {
+ $changes->modifyNode($this->parent, [
+ 'stateOverrides' => array_merge($this->parent->getStateOverrides(), [
+ $nodeName => $stateOverrides
+ ])
+ ]);
+ }
+
+ if ($this->bp->getMetadata()->isManuallyOrdered() && ($newPos = count($children) - 1) > $previousPos) {
+ $changes->moveNode(
+ $node,
+ $newPos,
+ $previousPos,
+ $this->parent->getName(),
+ $this->parent->getName()
+ );
+ }
+
+ unset($changes);
+ }
+}
diff --git a/application/forms/MoveNodeForm.php b/application/forms/MoveNodeForm.php
new file mode 100644
index 0000000..81d15c7
--- /dev/null
+++ b/application/forms/MoveNodeForm.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Web;
+use Icinga\Exception\Http\HttpException;
+use Icinga\Module\Businessprocess\BpConfig;
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Exception\ModificationError;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+use Icinga\Module\Businessprocess\Web\Form\CsrfToken;
+use Icinga\Web\Session;
+use Icinga\Web\Session\SessionNamespace;
+
+class MoveNodeForm extends BpConfigBaseForm
+{
+ /** @var BpConfig */
+ protected $bp;
+
+ /** @var Node */
+ protected $node;
+
+ /** @var BpNode */
+ protected $parentNode;
+
+ /** @var SessionNamespace */
+ protected $session;
+
+ public function __construct($options = null)
+ {
+ parent::__construct($options);
+
+ // Zend's plugin loader reverses the order of added prefix paths thus trying our paths first before trying
+ // Zend paths
+ $this->addPrefixPaths(array(
+ array(
+ 'prefix' => 'Icinga\\Web\\Form\\Element\\',
+ 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Element'),
+ 'type' => static::ELEMENT
+ ),
+ array(
+ 'prefix' => 'Icinga\\Web\\Form\\Decorator\\',
+ 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Decorator'),
+ 'type' => static::DECORATOR
+ )
+ ));
+ }
+
+ public function setup()
+ {
+ $this->addElement(
+ 'text',
+ 'parent',
+ [
+ 'allowEmpty' => true,
+ 'filters' => ['Null'],
+ 'validators' => [
+ ['Callback', true, [
+ 'callback' => function ($name) {
+ return empty($name) || $this->bp->hasBpNode($name);
+ },
+ 'messages' => [
+ 'callbackValue' => $this->translate('No process found with name %value%')
+ ]
+ ]]
+ ]
+ ]
+ );
+ $this->addElement(
+ 'number',
+ 'from',
+ [
+ 'required' => true,
+ 'min' => 0
+ ]
+ );
+ $this->addElement(
+ 'number',
+ 'to',
+ [
+ 'required' => true,
+ 'min' => 0
+ ]
+ );
+ $this->addElement(
+ 'hidden',
+ 'csrfToken',
+ [
+ 'required' => true
+ ]
+ );
+
+ $this->setSubmitLabel('movenode');
+ }
+
+ /**
+ * @param Node $node
+ * @return $this
+ */
+ public function setNode(Node $node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ /**
+ * @param BpNode|null $node
+ * @return $this
+ */
+ public function setParentNode(BpNode $node = null)
+ {
+ $this->parentNode = $node;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ if (! CsrfToken::isValid($this->getValue('csrfToken'))) {
+ throw new HttpException(403, 'nope');
+ }
+
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+ if (! $this->bp->getMetadata()->isManuallyOrdered()) {
+ $changes->applyManualOrder();
+ }
+
+ try {
+ $changes->moveNode(
+ $this->node,
+ $this->getValue('from'),
+ $this->getValue('to'),
+ $this->getValue('parent'),
+ $this->parentNode !== null ? $this->parentNode->getName() : null
+ );
+ } catch (ModificationError $e) {
+ $this->notifyError($e->getMessage());
+ /** @var Web $app */
+ $app = Icinga::app();
+ $app->getResponse()
+ // Web 2's JS forces a content update for non-200s. Our own JS
+ // can't prevent this, hence we're not making this a 400 :(
+ //->setHttpResponseCode(400)
+ ->setHeader('X-Icinga-Container', 'ignore')
+ ->sendResponse();
+ exit;
+ }
+
+ // Trigger session destruction to make sure it get's stored.
+ unset($changes);
+
+ $this->notifySuccess($this->getSuccessMessage($this->translate('Node order updated')));
+
+ $response = $this->getRequest()->getResponse()
+ ->setHeader('X-Icinga-Container', 'ignore')
+ ->setHeader('X-Icinga-Extra-Updates', implode(';', [
+ $this->getRequest()->getHeader('X-Icinga-Container'),
+ $this->getSuccessUrl()->getAbsoluteUrl()
+ ]));
+
+ Session::getSession()->write();
+ $response->sendResponse();
+ exit;
+ }
+
+ public function hasBeenSent()
+ {
+ return true; // This form has no id
+ }
+}
diff --git a/application/forms/ProcessForm.php b/application/forms/ProcessForm.php
new file mode 100644
index 0000000..126fe9b
--- /dev/null
+++ b/application/forms/ProcessForm.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\BpNode;
+use Icinga\Module\Businessprocess\Modification\ProcessChanges;
+use Icinga\Module\Businessprocess\Node;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+use Icinga\Web\Notification;
+use Icinga\Web\View;
+
+class ProcessForm extends BpConfigBaseForm
+{
+ /** @var BpNode */
+ protected $node;
+
+ public function setup()
+ {
+ if ($this->node !== null) {
+ /** @var View $view */
+ $view = $this->getView();
+
+ $this->addHtml(
+ '<h2>' . $view->escape(
+ sprintf($this->translate('Modify "%s"'), $this->node->getAlias())
+ ) . '</h2>'
+ );
+ }
+
+ $this->addElement('text', 'name', [
+ 'label' => $this->translate('ID'),
+ 'value' => (string) $this->node,
+ 'required' => true,
+ 'readonly' => $this->node ? true : null,
+ 'description' => $this->translate('This is the unique identifier of this process')
+ ]);
+
+ $this->addElement('text', 'alias', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'Usually this name will be shown for this node. Equals ID'
+ . ' if not given'
+ ),
+ ));
+
+ $this->addElement('select', 'operator', array(
+ 'label' => $this->translate('Operator'),
+ 'required' => true,
+ 'multiOptions' => Node::getOperators()
+ ));
+
+ if ($this->node !== null) {
+ $display = $this->node->getDisplay() ?: 1;
+ } else {
+ $display = 1;
+ }
+ $this->addElement('select', 'display', array(
+ 'label' => $this->translate('Visualization'),
+ 'required' => true,
+ 'description' => $this->translate(
+ 'Where to show this process'
+ ),
+ 'multiOptions' => array(
+ "$display" => $this->translate('Toplevel Process'),
+ '0' => $this->translate('Subprocess only'),
+ )
+ ));
+
+ $this->addElement('text', 'url', array(
+ 'label' => $this->translate('Info URL'),
+ 'description' => $this->translate(
+ 'URL pointing to more information about this node'
+ )
+ ));
+
+ if ($node = $this->node) {
+ if ($node->hasAlias()) {
+ $this->getElement('alias')->setValue($node->getAlias());
+ }
+ $this->getElement('operator')->setValue($node->getOperator());
+ $this->getElement('display')->setValue($node->getDisplay());
+ if ($node->hasInfoUrl()) {
+ $this->getElement('url')->setValue($node->getInfoUrl());
+ }
+ }
+ }
+
+ /**
+ * @param BpNode $node
+ * @return $this
+ */
+ public function setNode(BpNode $node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $changes = ProcessChanges::construct($this->bp, $this->session);
+
+ $modifications = array();
+ $alias = $this->getValue('alias');
+ $operator = $this->getValue('operator');
+ $display = $this->getValue('display');
+ $url = $this->getValue('url');
+ if (empty($url)) {
+ $url = null;
+ }
+ if (empty($alias)) {
+ $alias = null;
+ }
+ // TODO: rename
+
+ if ($node = $this->node) {
+ if ($display !== $node->getDisplay()) {
+ $modifications['display'] = $display;
+ }
+ if ($operator !== $node->getOperator()) {
+ $modifications['operator'] = $operator;
+ }
+ if ($url !== $node->getInfoUrl()) {
+ $modifications['infoUrl'] = $url;
+ }
+ if ($alias !== $node->getAlias()) {
+ $modifications['alias'] = $alias;
+ }
+ } else {
+ $modifications = array(
+ 'display' => $display,
+ 'operator' => $operator,
+ 'infoUrl' => $url,
+ 'alias' => $alias,
+ );
+ }
+
+ if (! empty($modifications)) {
+ if ($this->node === null) {
+ $changes->createNode($this->getValue('name'), $modifications);
+ } else {
+ $changes->modifyNode($this->node, $modifications);
+ }
+
+ Notification::success(
+ sprintf(
+ 'Process %s has been modified',
+ $this->bp->getName()
+ )
+ );
+ }
+
+ // Trigger session destruction to make sure it get's stored.
+ // TODO: figure out why this is necessary, might be an unclean shutdown on redirect
+ unset($changes);
+
+ parent::onSuccess();
+ }
+}
diff --git a/application/forms/SimulationForm.php b/application/forms/SimulationForm.php
new file mode 100644
index 0000000..04a0f56
--- /dev/null
+++ b/application/forms/SimulationForm.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Icinga\Module\Businessprocess\Forms;
+
+use Icinga\Module\Businessprocess\MonitoredNode;
+use Icinga\Module\Businessprocess\Simulation;
+use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm;
+use Icinga\Web\View;
+
+class SimulationForm extends BpConfigBaseForm
+{
+ /** @var MonitoredNode */
+ protected $node;
+
+ /** @var ?MonitoredNode */
+ protected $simulatedNode;
+
+ /** @var Simulation */
+ protected $simulation;
+
+ public function setup()
+ {
+ $states = $this->enumStateNames();
+
+ // TODO: Fetch state from object
+ if ($this->simulatedNode) {
+ $simulatedState = $this->simulatedNode->getState();
+ $states[$simulatedState] = sprintf(
+ '%s (%s)',
+ $this->node->getStateName($simulatedState),
+ $this->translate('Current simulation')
+ );
+ $node = $this->simulatedNode;
+ $hasSimulation = true;
+ } else {
+ $hasSimulation = false;
+ $node = $this->node;
+ }
+
+ /** @var View $view */
+ $view = $this->getView();
+ if ($hasSimulation) {
+ $title = $this->translate('Modify simulation for %s');
+ } else {
+ $title = $this->translate('Add simulation for %s');
+ }
+ $this->addHtml(
+ '<h2>'
+ . $view->escape(sprintf($title, $node->getAlias() ?? $node->getName()))
+ . '</h2>'
+ );
+
+ $this->addElement('select', 'state', array(
+ 'label' => $this->translate('State'),
+ 'multiOptions' => $states,
+ 'class' => 'autosubmit',
+ 'value' => $this->simulatedNode ? $node->getState() : null,
+ ));
+
+ $sentState = $this->getSentValue('state');
+ if (in_array($sentState, array('0', '99'))) {
+ return;
+ }
+
+ if ($hasSimulation || ($sentState !== null && ctype_digit($sentState))) {
+ $this->addElement('checkbox', 'acknowledged', array(
+ 'label' => $this->translate('Acknowledged'),
+ 'value' => $node->isAcknowledged(),
+ ));
+
+ $this->addElement('checkbox', 'in_downtime', array(
+ 'label' => $this->translate('In downtime'),
+ 'value' => $node->isInDowntime(),
+ ));
+ }
+
+ $this->setSubmitLabel($this->translate('Apply'));
+ }
+
+ public function setNode($node)
+ {
+ $this->node = $node;
+ return $this;
+ }
+
+ public function setSimulation(Simulation $simulation)
+ {
+ $this->simulation = $simulation;
+
+ $name = $this->node->getName();
+ if ($simulation->hasNode($name)) {
+ $this->simulatedNode = clone($this->node);
+ $s = $simulation->getNode($name);
+ $this->simulatedNode->setState($s->state)
+ ->setAck($s->acknowledged)
+ ->setDowntime($s->in_downtime)
+ ->setMissing(false);
+ }
+
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $nodeName = $this->node->getName();
+ $state = $this->getValue('state');
+
+ if ($state !== null && ctype_digit($state)) {
+ $this->notifySuccess($this->translate('Simulation has been set'));
+ $this->simulation->set($nodeName, (object) array(
+ 'state' => $this->getValue('state'),
+ 'acknowledged' => $this->getValue('acknowledged'),
+ 'in_downtime' => $this->getValue('in_downtime'),
+ ));
+ } else {
+ if ($this->simulation->remove($nodeName)) {
+ $this->notifySuccess($this->translate('Simulation has been removed'));
+ }
+ }
+
+ parent::onSuccess();
+ }
+
+ /**
+ * @return array
+ */
+ protected function enumStateNames()
+ {
+ $states = array(
+ null => sprintf(
+ $this->translate('Use current state (%s)'),
+ $this->translate($this->node->getStateName())
+ )
+ ) + $this->node->enumStateNames();
+
+ return $states;
+ }
+}
diff --git a/application/views/helpers/FormSimpleNote.php b/application/views/helpers/FormSimpleNote.php
new file mode 100644
index 0000000..d8315f4
--- /dev/null
+++ b/application/views/helpers/FormSimpleNote.php
@@ -0,0 +1,15 @@
+<?php
+
+// Avoid complaints about missing namespace and invalid class name
+// @codingStandardsIgnoreStart
+class Zend_View_Helper_FormSimpleNote extends Zend_View_Helper_FormElement
+{
+ // @codingStandardsIgnoreEnd
+
+ public function formSimpleNote($name, $value = null)
+ {
+ $info = $this->_getInfo($name, $value);
+ extract($info); // name, value, attribs, options, listsep, disable
+ return $value;
+ }
+}
diff --git a/application/views/helpers/RenderStateBadges.php b/application/views/helpers/RenderStateBadges.php
new file mode 100644
index 0000000..70633aa
--- /dev/null
+++ b/application/views/helpers/RenderStateBadges.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @deprecated
+ * @codingStandardsIgnoreStart
+ */
+class Zend_View_Helper_RenderStateBadges extends Zend_View_Helper_Abstract
+{
+ // @codingStandardsIgnoreEnd
+ public function renderStateBadges($summary)
+ {
+ $html = '';
+
+ foreach ($summary as $state => $cnt) {
+ if ($cnt === 0
+ || $state === 'OK'
+ || $state === 'UP'
+ ) {
+ continue;
+ }
+
+ $html .= '<span class="badge badge-' . strtolower($state)
+ . '" title="' . mt('monitoring', $state) . '">'
+ . $cnt . '</span>';
+ }
+
+ if ($html !== '') {
+ $html = '<div class="badges">' . $html . '</div>';
+ }
+
+ return $html;
+ }
+}
diff --git a/application/views/scripts/default.phtml b/application/views/scripts/default.phtml
new file mode 100644
index 0000000..3e2cc59
--- /dev/null
+++ b/application/views/scripts/default.phtml
@@ -0,0 +1,2 @@
+<?= $this->controls->render() ?>
+<?= $this->content->render() ?>
diff --git a/application/views/scripts/host/show.phtml b/application/views/scripts/host/show.phtml
new file mode 100644
index 0000000..413baf2
--- /dev/null
+++ b/application/views/scripts/host/show.phtml
@@ -0,0 +1,13 @@
+<?php
+/** @var \Icinga\Web\View $this */
+/** @var \Icinga\Web\Widget\Tabs $tabs */
+/** @var string $host */
+?>
+<div class="controls">
+ <?= $tabs->showOnlyCloseButton() ?>
+</div>
+<div class="content restricted">
+ <h1><?= $this->translate('Access Denied') ?></h1>
+ <p><?= sprintf($this->translate('You are lacking permission to access host "%s".'), $this->escape($host)) ?></p>
+ <a href="#" class="close-container-control action-link"><?= $this->icon('cancel') ?><?= $this->translate('Hide this message') ?></a>
+</div>
diff --git a/application/views/scripts/process/source.phtml b/application/views/scripts/process/source.phtml
new file mode 100644
index 0000000..d5ba6bb
--- /dev/null
+++ b/application/views/scripts/process/source.phtml
@@ -0,0 +1,25 @@
+<?= $this->controls->render() ?>
+
+<div class="content">
+<?php if ($this->showDiff): ?>
+<div class="diff">
+<?= $this->diff->render() ?>
+</div>
+<?php else: ?>
+<table class="sourcecode">
+<?php
+
+$cnt = 0;
+$lines = preg_split('~\r?\n~', $this->source);
+$len = ceil(log(count($lines), 10));
+$rowhtml = sprintf('<tr><th>%%0%dd: </th><td>%%s<br></td></tr>', $len);
+
+foreach ($lines as $line) {
+ $cnt++;
+ printf($rowhtml, $cnt, $this->escape($line));
+}
+
+?>
+</table>
+<?php endif ?>
+</div>
diff --git a/application/views/scripts/service/show.phtml b/application/views/scripts/service/show.phtml
new file mode 100644
index 0000000..205b3f7
--- /dev/null
+++ b/application/views/scripts/service/show.phtml
@@ -0,0 +1,14 @@
+<?php
+/** @var \Icinga\Web\View $this */
+/** @var \Icinga\Web\Widget\Tabs $tabs */
+/** @var string $host */
+/** @var string $service */
+?>
+<div class="controls">
+ <?= $tabs->showOnlyCloseButton() ?>
+</div>
+<div class="content restricted">
+ <h1><?= $this->escape($this->translate('Access Denied')) ?></h1>
+ <p><?= $this->escape(sprintf($this->translate('You are lacking permission to access service "%s" on host "%s"'), $service, $host)) ?></p>
+ <a href="#" class="close-container-control action-link"><?= $this->icon('cancel') ?><?= $this->translate('Hide this message') ?></a>
+</div>