diff --git a/application/controllers/ProcessController.php b/application/controllers/ProcessController.php index 0cfced2..06e5bb8 100644 --- a/application/controllers/ProcessController.php +++ b/application/controllers/ProcessController.php @@ -6,6 +6,8 @@ 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; @@ -269,13 +271,25 @@ class ProcessController extends Controller $canEdit = $bp->getMetadata()->canModify(); if ($action === 'add' && $canEdit) { - $form = $this->loadForm('AddNode') - ->setSuccessUrl(Url::fromRequest()->without('action')) - ->setStorage($this->storage()) + $form = (new AddNodeForm()) ->setProcess($bp) ->setParentNode($node) + ->setStorage($this->storage()) ->setSession($this->session()) - ->handleRequest(); + ->on(AddNodeForm::ON_SUCCESS, function () { + $this->redirectNow(Url::fromRequest()->without('action')); + }) + ->handleRequest($this->getServerRequest()); + + if ($form->hasElement('children')) { + foreach ($form->getElement('children')->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')) @@ -283,13 +297,15 @@ class ProcessController extends Controller ->setSession($this->session()) ->handleRequest(); } elseif ($action === 'editmonitored' && $canEdit) { - $form = $this->loadForm('EditNode') - ->setSuccessUrl(Url::fromRequest()->without('action')) + $form = (new EditNodeForm()) ->setProcess($bp) ->setNode($bp->getNode($this->params->get('editmonitorednode'))) ->setParentNode($node) ->setSession($this->session()) - ->handleRequest(); + ->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')) @@ -349,8 +365,11 @@ class ProcessController extends Controller return; } - if ($this->params->get('action')) { - $this->setAutorefreshInterval(45); + 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); } 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 @@ +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', ' on '), + $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', ' on '), + $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 index 4759d99..68299b7 100644 --- a/application/forms/AddNodeForm.php +++ b/application/forms/AddNodeForm.php @@ -5,509 +5,406 @@ namespace Icinga\Module\Businessprocess\Forms; use Exception; use Icinga\Module\Businessprocess\BpConfig; use Icinga\Module\Businessprocess\BpNode; -use Icinga\Module\Businessprocess\Common\EnumList; use Icinga\Module\Businessprocess\Common\Sort; -use Icinga\Module\Businessprocess\ImportedNode; use Icinga\Module\Businessprocess\Modification\ProcessChanges; use Icinga\Module\Businessprocess\Node; -use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm; -use Icinga\Module\Businessprocess\Web\Form\Validator\NoDuplicateChildrenValidator; +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 BpConfigBaseForm +class AddNodeForm extends CompatForm { - use EnumList; use Sort; + use Translation; - /** @var BpNode */ + /** @var Storage */ + protected $storage; + + /** @var ?BpConfig */ + protected $bp; + + /** @var ?BpNode */ protected $parent; - protected $objectList = array(); - - protected $processList = array(); - - public function setup() - { - $view = $this->getView(); - if ($this->hasParentNode()) { - $this->addHtml( - '

' . $view->escape( - sprintf($this->translate('Add a node to %s'), $this->parent->getAlias()) - ) . '

' - ); - } else { - $this->addHtml( - '

' . $this->translate('Add a new root node') . '

' - ); - } - - $type = $this->selectNodeType(); - switch ($type) { - case 'host': - $this->selectHost(); - break; - case 'service': - $this->selectService(); - break; - case 'process': - $this->selectProcess(); - break; - case 'new-process': - $this->addNewProcess(); - break; - case 'hosts_from_filter': - $this->selectHostsFromFilter(); - break; - case 'services_from_filter': - $this->selectServicesFromFilter(); - break; - case null: - $this->setSubmitLabel($this->translate('Next')); - return; - } - } - - protected function addNewProcess() - { - $this->addElement('text', 'name', array( - 'label' => $this->translate('ID'), - 'required' => true, - 'description' => $this->translate( - 'This is the unique identifier of this process' - ), - 'validators' => [ - ['Callback', true, [ - 'callback' => function ($value) { - if ($this->hasParentNode()) { - return ! $this->parent->hasChild($value); - } - - return ! $this->bp->hasRootNode($value); - }, - 'messages' => [ - 'callbackValue' => $this->translate('%value% is already defined in 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() - )); - - $display = 1; - if ($this->bp->getMetadata()->isManuallyOrdered() && ! $this->bp->isEmpty()) { - $rootNodes = self::applyManualSorting($this->bp->getRootNodes()); - $display = end($rootNodes)->getDisplay() + 1; - } - $this->addElement('select', 'display', array( - 'label' => $this->translate('Visualization'), - 'required' => true, - 'description' => $this->translate( - 'Where to show this process' - ), - 'value' => $this->hasParentNode() ? '0' : "$display", - 'multiOptions' => array( - "$display" => $this->translate('Toplevel Process'), - '0' => $this->translate('Subprocess only'), - ) - )); - - $this->addElement('text', 'infoUrl', array( - 'label' => $this->translate('Info URL'), - 'description' => $this->translate( - 'URL pointing to more information about this node' - ) - )); - } + /** @var SessionNamespace */ + protected $session; /** - * @return string|null - */ - protected function selectNodeType() - { - $types = array(); - if ($this->hasParentNode()) { - $types['host'] = $this->translate('Host'); - $types['service'] = $this->translate('Service'); - $types['hosts_from_filter'] = $this->translate('Hosts from filter'); - $types['services_from_filter'] = $this->translate('Services from filter'); - } elseif (! $this->hasProcesses()) { - $this->addElement('hidden', 'node_type', array( - 'ignore' => true, - 'decorators' => array('ViewHelper'), - 'value' => 'new-process' - )); - - return 'new-process'; - } - - if ($this->hasProcesses() || ($this->hasParentNode() && $this->hasMoreConfigs())) { - $types['process'] = $this->translate('Existing Process'); - } - - $types['new-process'] = $this->translate('New Process Node'); - - $this->addElement('select', 'node_type', array( - 'label' => $this->translate('Node type'), - 'required' => true, - 'description' => $this->translate( - 'The node type you want to add' - ), - 'ignore' => true, - 'class' => 'autosubmit', - 'multiOptions' => $this->optionalEnum($types) - )); - - return $this->getSentValue('node_type'); - } - - protected function selectHost() - { - $this->addElement('multiselect', 'children', [ - 'label' => $this->translate('Hosts'), - 'required' => true, - 'size' => 8, - 'multiOptions' => $this->enumHostList(), - 'description' => $this->translate( - 'Hosts that should be part of this business process node' - ), - 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]] - ]); - - $this->addHostOverrideCheckbox(); - if ($this->getSentValue('host_override') === '1') { - $this->addHostOverrideElement(); - } - } - - protected function selectService() - { - $this->addHostElement(); - if ($host = $this->getSentValue('host')) { - $this->addServicesElement($host); - $this->addServiceOverrideCheckbox(); - - if ($this->getSentValue('service_override') === '1') { - $this->addServiceOverrideElement(); - } - } else { - $this->setSubmitLabel($this->translate('Next')); - } - } - - protected function addHostElement() - { - $this->addElement('select', 'host', array( - 'label' => $this->translate('Host'), - 'required' => true, - 'ignore' => true, - 'class' => 'autosubmit', - 'multiOptions' => $this->optionalEnum($this->enumHostForServiceList()), - )); - } - - protected function addServicesElement($host) - { - $this->addElement('multiselect', 'children', [ - 'label' => $this->translate('Services'), - 'required' => true, - 'size' => 8, - 'multiOptions' => $this->enumServiceList($host), - 'description' => $this->translate( - 'Services that should be part of this business process node' - ), - 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]] - ]); - } - - protected function addFilteredHostsElement($filter) - { - $this->addElement('submit', 'refresh', [ - 'label' => $this->translate('Refresh'), - 'class' => 'refresh-filter' - ]); - $this->addElement('multiselect', 'children', [ - 'label' => $this->translate('Hosts'), - 'required' => true, - 'size' => 8, - 'multiOptions' => $this->enumHostListByFilter($filter), - 'description' => $this->translate( - 'Hosts that should be part of this business process node' - ), - 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]] - ]); - } - - protected function addFilteredServicesElement($filter) - { - $this->addElement('submit', 'refresh', [ - 'label' => $this->translate('Refresh'), - 'class' => 'refresh-filter' - ]); - $this->addElement('multiselect', 'children', [ - 'label' => $this->translate('Services'), - 'required' => true, - 'size' => 8, - 'multiOptions' => $this->enumServiceListByFilter($filter), - 'description' => $this->translate( - 'Services that should be part of this business process node' - ), - 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]] - ]); - } - - protected function addFilterElement() - { - $this->addElement('text', 'filter', array( - 'label' => $this->translate('Filter'), - 'required' => true, - 'ignore' => true - )); - } - - protected function addFileElement() - { - $this->addElement('select', 'file', [ - 'label' => $this->translate('File'), - 'required' => true, - 'ignore' => true, - 'value' => $this->bp->getName(), - 'class' => 'autosubmit', - 'multiOptions' => $this->optionalEnum($this->enumConfigs()), - 'description' => $this->translate( - 'Choose a different configuration file to import its processes' - ) - ]); - } - - protected function addHostOverrideCheckbox() - { - $this->addElement('checkbox', 'host_override', [ - 'ignore' => true, - 'class' => 'autosubmit', - 'label' => $this->translate('Override Host State'), - 'description' => $this->translate('Enable host state overrides') - ]); - } - - protected function addHostOverrideElement() - { - $this->addElement('stateOverrides', 'stateOverrides', [ - 'required' => true, - 'label' => $this->translate('State Overrides'), - 'states' => $this->enumHostStateList() - ]); - } - - protected function addServiceOverrideCheckbox() - { - $this->addElement('checkbox', 'service_override', [ - 'ignore' => true, - 'class' => 'autosubmit', - 'label' => $this->translate('Override Service State'), - 'description' => $this->translate('Enable service state overrides') - ]); - } - - protected function addServiceOverrideElement() - { - $this->addElement('stateOverrides', 'stateOverrides', [ - 'required' => true, - 'label' => $this->translate('State Overrides'), - 'states' => $this->enumServiceStateList() - ]); - } - - protected function selectHostsFromFilter() - { - $this->addFilterElement(); - if ($filter = $this->getSentValue('filter')) { - $this->addFilteredHostsElement($filter); - } else { - $this->setSubmitLabel($this->translate('Next')); - } - } - - protected function selectServicesFromFilter() - { - $this->addFilterElement(); - if ($filter = $this->getSentValue('filter')) { - $this->addFilteredServicesElement($filter); - } else { - $this->setSubmitLabel($this->translate('Next')); - } - } - - protected function selectProcess() - { - if ($this->hasParentNode()) { - $this->addFileElement(); - } - - if (($file = $this->getSentValue('file')) || !$this->hasParentNode()) { - $this->addElement('multiselect', 'children', [ - 'label' => $this->translate('Process nodes'), - 'required' => true, - 'size' => 8, - 'multiOptions' => $this->enumProcesses($file), - 'description' => $this->translate( - 'Other processes that should be part of this business process node' - ), - 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]] - ]); - } else { - $this->setSubmitLabel($this->translate('Next')); - } - } - - /** - * @param BpNode|null $node + * Set the storage to use + * + * @param Storage $storage + * * @return $this */ - public function setParentNode(BpNode $node = null) + public function setStorage(Storage $storage): self { - $this->parent = $node; + $this->storage = $storage; + return $this; } /** - * @return bool + * Set the affected configuration + * + * @param BpConfig $bp + * + * @return $this */ - public function hasParentNode() + public function setProcess(BpConfig $bp): self { - return $this->parent !== null; - } + $this->bp = $bp; - protected function hasProcesses() - { - return count($this->enumProcesses()) > 0; + return $this; } /** - * @param string $file - * @return array + * Set the affected sub-process + * + * @param ?BpNode $node + * + * @return $this */ - protected function enumProcesses($file = null) + public function setParentNode(BpNode $node = null): self { - $list = array(); + $this->parent = $node; - $parents = array(); + return $this; + } - $differentFile = $file !== null && $file !== $this->bp->getName(); + /** + * Set the user's session + * + * @param SessionNamespace $session + * + * @return $this + */ + public function setSession(SessionNamespace $session): self + { + $this->session = $session; - if (! $differentFile && $this->hasParentNode()) { - $this->collectAllParents($this->parent, $parents); - $parents[$this->parent->getName()] = $this->parent; - } + return $this; + } - $bp = $this->bp; - if ($differentFile) { - try { - $bp = $this->storage->loadProcess($file); - } catch (Exception $e) { - $this->addError('Cannot add invalid config file'); - - return $list; + 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 = []; } } - foreach ($bp->getNodes() as $node) { - if (! $node instanceof ImportedNode && $node instanceof BpNode && ! isset($parents[$node->getName()])) { - $name = $node->getName(); - if ($differentFile) { - $name = '@' . $file . ':' . $name; + $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()); } - $list[$name] = $node->getName(); // display name? + 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'; } - return $list; - } + $this->addElement($this->createChildrenElementForObjects( + $this->translate('Hosts'), + $suggestionsPath + )); - protected function hasMoreConfigs() - { - $configs = $this->enumConfigs(); - return !empty($configs); - } - - protected function enumConfigs() - { - return $this->storage->listProcesses(); - } - - /** - * Collect the given node's parents recursively into the given array by their names - * - * @param BpNode $node - * @param BpNode[] $parents - */ - protected function collectAllParents(BpNode $node, array &$parents) - { - foreach ($node->getParents() as $parent) { - $parents[$parent->getName()] = $parent; - $this->collectAllParents($parent, $parents); + $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') + ] + ])); } } - public function onSuccess() + 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); - switch ($this->getValue('node_type')) { - case 'host': - case 'service': + + $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 { + $children = array_unique(array_map(function ($term) { + return $term->getSearchValue(); + }, $this->getElement('children')->getTerms())); + + if ($nodeType === 'host' || $nodeType === 'service') { $stateOverrides = $this->getValue('stateOverrides'); if (! empty($stateOverrides)) { $childOverrides = []; - foreach ($this->getValue('children') as $service) { - $childOverrides[$service] = $stateOverrides; + foreach ($children as $nodeName) { + $childOverrides[$nodeName] = $stateOverrides; } $changes->modifyNode($this->parent, [ 'stateOverrides' => array_merge($this->parent->getStateOverrides(), $childOverrides) ]); } + } - // Fallthrough - case 'process': - case 'hosts_from_filter': - case 'services_from_filter': - if ($this->hasParentNode()) { - $changes->addChildrenToNode($this->getValue('children'), $this->parent); - } else { - foreach ($this->getValue('children') as $nodeName) { - $changes->copyNode($nodeName); - } + if ($this->parent !== null) { + $changes->addChildrenToNode($children, $this->parent); + } else { + foreach ($children as $nodeName) { + $changes->copyNode($nodeName); } - - break; - case 'new-process': - $properties = $this->getValues(); - unset($properties['name']); - if (! $properties['alias']) { - unset($properties['alias']); - } - if ($this->hasParentNode()) { - $properties['parentName'] = $this->parent->getName(); - } - $changes->createNode(BpConfig::escapeName($this->getValue('name')), $properties); - break; + } } - // 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/EditNodeForm.php b/application/forms/EditNodeForm.php index a4cd919..bd1592b 100644 --- a/application/forms/EditNodeForm.php +++ b/application/forms/EditNodeForm.php @@ -4,397 +4,312 @@ namespace Icinga\Module\Businessprocess\Forms; use Icinga\Module\Businessprocess\BpConfig; use Icinga\Module\Businessprocess\BpNode; -use Icinga\Module\Businessprocess\Common\EnumList; use Icinga\Module\Businessprocess\Modification\ProcessChanges; use Icinga\Module\Businessprocess\Node; -use Icinga\Module\Businessprocess\Web\Form\BpConfigBaseForm; -use Icinga\Module\Businessprocess\Web\Form\Validator\NoDuplicateChildrenValidator; +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 BpConfigBaseForm +class EditNodeForm extends CompatForm { - use EnumList; + use Translation; - /** @var Node */ + /** @var ?BpConfig */ + protected $bp; + + /** @var ?Node */ protected $node; - /** @var BpNode */ + /** @var ?BpNode */ protected $parent; - protected $objectList = array(); - - protected $processList = array(); - - protected $service; - - protected $host; - - public function setup() - { - [$this->host, $suffix] = BpConfig::splitNodeName($this->getNode()->getName()); - if ($suffix !== 'Hoststatus') { - $this->service = $suffix; - } - - $view = $this->getView(); - $nodeName = $this->getNode()->getAlias() ?? $this->getNode()->getName(); - $this->addHtml( - '

' . $view->escape( - sprintf($this->translate('Modify "%s"'), $nodeName) - ) . '

' - ); - - $monitoredNodeType = null; - if ($this->isService()) { - $monitoredNodeType = 'service'; - } else { - $monitoredNodeType = 'host'; - } - - $type = $this->selectNodeType($monitoredNodeType); - switch ($type) { - case 'host': - $this->selectHost(); - break; - case 'service': - $this->selectService(); - break; - case 'process': - $this->selectProcess(); - break; - case 'new-process': - $this->addNewProcess(); - break; - case null: - $this->setSubmitLabel($this->translate('Next')); - return; - } - } - - protected function isService() - { - if (strpos($this->getNode()->getName(), ';Hoststatus')) { - return false; - } - return true; - } - - protected function addNewProcess() - { - $this->addElement('text', 'name', array( - 'label' => $this->translate('ID'), - 'required' => true, - 'disabled' => true, - '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() - )); - - $display = $this->getNode()->getDisplay() ?: 1; - $this->addElement('select', 'display', array( - 'label' => $this->translate('Visualization'), - 'required' => true, - 'description' => $this->translate( - 'Where to show this process' - ), - 'value' => $display, - 'multiOptions' => array( - "$display" => $this->translate('Toplevel Process'), - '0' => $this->translate('Subprocess only'), - ) - )); - - $this->addElement('text', 'infoUrl', array( - 'label' => $this->translate('Info URL'), - 'description' => $this->translate( - 'URL pointing to more information about this node' - ) - )); - } + /** @var SessionNamespace */ + protected $session; /** - * @return string|null - */ - protected function selectNodeType($monitoredNodeType = null) - { - if ($this->hasParentNode()) { - $this->addElement('hidden', 'node_type', [ - 'disabled' => true, - 'decorators' => ['ViewHelper'], - 'value' => $monitoredNodeType - ]); - - return $monitoredNodeType; - } elseif (! $this->hasProcesses()) { - $this->addElement('hidden', 'node_type', array( - 'ignore' => true, - 'decorators' => array('ViewHelper'), - 'value' => 'new-process' - )); - - return 'new-process'; - } - } - - protected function selectHost() - { - $this->addElement('select', 'children', array( - 'required' => true, - 'value' => $this->getNode()->getName(), - 'multiOptions' => $this->enumHostList(), - 'label' => $this->translate('Host'), - 'description' => $this->translate('The host for this business process node'), - 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]] - )); - - $this->addHostOverrideCheckbox(); - $hostOverrideSent = $this->getSentValue('host_override'); - if ($hostOverrideSent === '1' - || ($hostOverrideSent === null && $this->getElement('host_override')->isChecked()) - ) { - $this->addHostOverrideElement(); - } - } - - protected function selectService() - { - $this->addHostElement(); - - if ($this->getSentValue('hosts') === null) { - $this->addServicesElement($this->host); - $this->addServiceOverrideCheckbox(); - if ($this->getElement('service_override')->isChecked() || $this->getSentValue('service_override') === '1') { - $this->addServiceOverrideElement(); - } - } elseif ($host = $this->getSentValue('hosts')) { - $this->addServicesElement($host); - $this->addServiceOverrideCheckbox(); - if ($this->getSentValue('service_override') === '1') { - $this->addServiceOverrideElement(); - } - } else { - $this->setSubmitLabel($this->translate('Next')); - } - } - - protected function addHostElement() - { - $this->addElement('select', 'hosts', array( - 'label' => $this->translate('Host'), - 'required' => true, - 'ignore' => true, - 'class' => 'autosubmit', - 'multiOptions' => $this->optionalEnum($this->enumHostForServiceList()), - )); - - $this->getElement('hosts')->setValue($this->host); - } - - protected function addHostOverrideCheckbox() - { - $this->addElement('checkbox', 'host_override', [ - 'ignore' => true, - 'class' => 'autosubmit', - 'value' => ! empty($this->parent->getStateOverrides($this->node->getName())), - 'label' => $this->translate('Override Host State'), - 'description' => $this->translate('Enable host state overrides') - ]); - } - - protected function addHostOverrideElement() - { - $this->addElement('stateOverrides', 'stateOverrides', [ - 'required' => true, - 'states' => $this->enumHostStateList(), - 'value' => $this->parent->getStateOverrides($this->node->getName()), - 'label' => $this->translate('State Overrides') - ]); - } - - protected function addServicesElement($host) - { - $this->addElement('select', 'children', array( - 'required' => true, - 'value' => $this->getNode()->getName(), - 'multiOptions' => $this->enumServiceList($host), - 'label' => $this->translate('Service'), - 'description' => $this->translate('The service for this business process node'), - 'validators' => [[new NoDuplicateChildrenValidator($this, $this->bp, $this->parent), true]] - )); - } - - protected function addServiceOverrideCheckbox() - { - $this->addElement('checkbox', 'service_override', [ - 'ignore' => true, - 'class' => 'autosubmit', - 'value' => ! empty($this->parent->getStateOverrides($this->node->getName())), - 'label' => $this->translate('Override Service State'), - 'description' => $this->translate('Enable service state overrides') - ]); - } - - protected function addServiceOverrideElement() - { - $this->addElement('stateOverrides', 'stateOverrides', [ - 'required' => true, - 'states' => $this->enumServiceStateList(), - 'value' => $this->parent->getStateOverrides($this->node->getName()), - 'label' => $this->translate('State Overrides') - ]); - } - - protected function selectProcess() - { - $this->addElement('multiselect', 'children', array( - 'label' => $this->translate('Process nodes'), - 'required' => true, - 'size' => 8, - 'multiOptions' => $this->enumProcesses(), - 'description' => $this->translate( - 'Other processes that should be part of this business process node' - ) - )); - } - - /** - * @param BpNode|null $node + * Set the affected configuration + * + * @param BpConfig $bp + * * @return $this */ - public function setParentNode(BpNode $node = null) + public function setProcess(BpConfig $bp): self { - $this->parent = $node; + $this->bp = $bp; + return $this; } /** - * @return bool - */ - public function hasParentNode() - { - return $this->parent !== null; - } - - protected function hasProcesses() - { - return count($this->enumProcesses()) > 0; - } - - protected function enumProcesses() - { - $list = array(); - - $parents = array(); - - if ($this->hasParentNode()) { - $this->collectAllParents($this->parent, $parents); - $parents[$this->parent->getName()] = $this->parent; - } - - foreach ($this->bp->getNodes() as $node) { - if ($node instanceof BpNode && ! isset($parents[$node->getName()])) { - $list[$node->getName()] = $node->getName(); // display name? - } - } - - return $list; - } - - /** - * Collect the given node's parents recursively into the given array by their names + * Set the affected node * - * @param BpNode $node - * @param BpNode[] $parents - */ - protected function collectAllParents(BpNode $node, array &$parents) - { - foreach ($node->getParents() as $parent) { - $parents[$parent->getName()] = $parent; - $this->collectAllParents($parent, $parents); - } - } - - /** * @param Node $node + * * @return $this */ - public function setNode(Node $node) + public function setNode(Node $node): self { $this->node = $node; + + $this->populate([ + 'node-search' => $node->getName(), + 'node-label' => $node->getAlias() + ]); + return $this; } - public function getNode() + /** + * Set the affected sub-process + * + * @param ?BpNode $node + * + * @return $this + */ + public function setParentNode(BpNode $node = null): self { - return $this->node; + $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; } - public function onSuccess() + /** + * 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); - switch ($this->getValue('node_type')) { - case 'host': - case 'service': - $stateOverrides = $this->getValue('stateOverrides') ?: []; - if (! empty($stateOverrides)) { - $stateOverrides = array_merge( - $this->parent->getStateOverrides(), - [$this->getValue('children') => $stateOverrides] - ); - } else { - $stateOverrides = $this->parent->getStateOverrides(); - unset($stateOverrides[$this->getValue('children')]); - } - - $changes->modifyNode($this->parent, ['stateOverrides' => $stateOverrides]); - // Fallthrough - case 'process': - $changes->addChildrenToNode($this->getValue('children'), $this->parent); - break; - case 'new-process': - $properties = $this->getValues(); - unset($properties['name']); - if ($this->hasParentNode()) { - $properties['parentName'] = $this->parent->getName(); - } - $changes->createNode($this->getValue('name'), $properties); - break; + $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() + ); } - // 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(); - } - - public function isValid($data) - { - // Don't allow to override disabled elements. This is probably too harsh - // but also wouldn't be necessary if this would be a Icinga\Web\Form... - foreach ($this->getElements() as $element) { - /** @var \Zend_Form_Element $element */ - if ($element->getAttrib('disabled')) { - $data[$element->getName()] = $element->getValue(); - } - } - - return parent::isValid($data); } } diff --git a/library/Businessprocess/BpNode.php b/library/Businessprocess/BpNode.php index 419f836..4b67622 100644 --- a/library/Businessprocess/BpNode.php +++ b/library/Businessprocess/BpNode.php @@ -173,6 +173,8 @@ class BpNode extends Node if (! empty($this->children)) { unset($this->children[$name]); } + + $this->childNames = array_values($this->childNames); } return $this; diff --git a/library/Businessprocess/Common/EnumList.php b/library/Businessprocess/Common/EnumList.php deleted file mode 100644 index 3419505..0000000 --- a/library/Businessprocess/Common/EnumList.php +++ /dev/null @@ -1,170 +0,0 @@ -useIcingaDbBackend()) { - $names = (new IcingaDbObject())->yieldHostnames(); - } else { - $names = $this->backend - ->select() - ->from('hostStatus', ['hostname' => 'host_name']) - ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects')) - ->order('host_name') - ->getQuery() - ->fetchColumn(); - } - - // fetchPairs doesn't seem to work when using the same column with - // different aliases twice - $res = array(); - foreach ($names as $name) { - $res[$name] = $name; - } - - return $res; - } - - protected function enumHostList() - { - if ($this->useIcingaDbBackend()) { - $names = (new IcingaDbObject())->yieldHostnames(); - } else { - $names = $this->backend - ->select() - ->from('hostStatus', ['hostname' => 'host_name']) - ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects')) - ->order('host_name') - ->getQuery() - ->fetchColumn(); - } - - // fetchPairs doesn't seem to work when using the same column with - // different aliases twice - $res = array(); - foreach ($names as $name) { - $res[BpConfig::joinNodeName($name, 'Hoststatus')] = $name; - } - - return $res; - } - - protected function enumServiceList($host) - { - if ($this->useIcingaDbBackend()) { - $names = (new IcingaDbObject())->yieldServicenames($host); - } else { - $names = $this->backend - ->select() - ->from('serviceStatus', ['service' => 'service_description']) - ->where('host_name', $host) - ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects')) - ->order('service_description') - ->getQuery() - ->fetchColumn(); - } - - $services = array(); - foreach ($names as $name) { - $services[BpConfig::joinNodeName($host, $name)] = $name; - } - - return $services; - } - - protected function enumHostListByFilter($filter) - { - if ($this->useIcingaDbBackend()) { - $names = (new IcingaDbObject())->yieldHostnames($filter); - } else { - $names = $this->backend - ->select() - ->from('hostStatus', ['hostname' => 'host_name']) - ->applyFilter(Filter::fromQueryString($filter)) - ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects')) - ->order('host_name') - ->getQuery() - ->fetchColumn(); - } - - // fetchPairs doesn't seem to work when using the same column with - // different aliases twice - $res = array(); - foreach ($names as $name) { - $res[BpConfig::joinNodeName($name, 'Hoststatus')] = $name; - } - - return $res; - } - - protected function enumServiceListByFilter($filter) - { - $services = array(); - - if ($this->useIcingaDbBackend()) { - $objects = (new IcingaDbObject())->fetchServices($filter); - foreach ($objects as $object) { - $services[BpConfig::joinNodeName($object->host->name, $object->name)] - = $object->host->name . ':' . $object->name; - } - } else { - $objects = $this->backend - ->select() - ->from('serviceStatus', ['host' => 'host_name', 'service' => 'service_description']) - ->applyFilter(Filter::fromQueryString($filter)) - ->applyFilter(MonitoringRestrictions::getRestriction('monitoring/filter/objects')) - ->order('service_description') - ->getQuery() - ->fetchAll(); - foreach ($objects as $object) { - $services[BpConfig::joinNodeName($object->host, $object->service)] - = $object->host . ':' . $object->service; - } - } - - return $services; - } - - protected function enumHostStateList() - { - $hostStateList = [ - 0 => $this->translate('UP'), - 1 => $this->translate('DOWN'), - 99 => $this->translate('PENDING') - ]; - - return $hostStateList; - } - - protected function enumServiceStateList() - { - $serviceStateList = [ - 0 => $this->translate('OK'), - 1 => $this->translate('WARNING'), - 2 => $this->translate('CRITICAL'), - 3 => $this->translate('UNKNOWN'), - 99 => $this->translate('PENDING'), - ]; - - return $serviceStateList; - } - - protected function useIcingaDbBackend() - { - if (Module::exists('icingadb')) { - return ! $this->bp->hasBackendName() && IcingadbSupport::useIcingaDbAsBackend(); - } - - return false; - } -} diff --git a/library/Businessprocess/Monitoring/Backend/Ido/Query/CustomVarJoinTemplateOverride.php b/library/Businessprocess/Monitoring/Backend/Ido/Query/CustomVarJoinTemplateOverride.php new file mode 100644 index 0000000..385ca59 --- /dev/null +++ b/library/Businessprocess/Monitoring/Backend/Ido/Query/CustomVarJoinTemplateOverride.php @@ -0,0 +1,84 @@ +customvarNameToTypeName($customvar); + $alias = ($type === 'host' ? 'hcv_' : 'scv_') . preg_replace('~[^a-zA-Z0-9_]~', '_', $name); + + // We're replacing any problematic char with an underscore, which will lead to duplicates, this avoids them + $from = $this->select->getPart(Zend_Db_Select::FROM); + for ($i = 2; array_key_exists($alias, $from); $i++) { + $alias = $alias . '_' . $i; + } + + $this->customVars[strtolower($customvar)] = $alias; + + if ($type === 'host') { + if ($this instanceof ServicecommentQuery + || $this instanceof ServicedowntimeQuery + || $this instanceof ServicecommenthistoryQuery + || $this instanceof ServicedowntimestarthistoryQuery + || $this instanceof ServiceflappingstarthistoryQuery + || $this instanceof ServicegroupQuery + || $this instanceof ServicenotificationQuery + || $this instanceof ServicestatehistoryQuery + || $this instanceof \Icinga\Module\Monitoring\Backend\Ido\Query\ServicestatusQuery + ) { + $this->requireVirtualTable('services'); + $leftcol = 's.host_object_id'; + } else { + $leftcol = 'ho.object_id'; + if (! $this->hasJoinedTable('ho')) { + $this->requireVirtualTable('hosts'); + } + } + } else { // $type === 'service' + $leftcol = 'so.object_id'; + if (! $this->hasJoinedTable('so')) { + $this->requireVirtualTable('services'); + } + } + + $mapped = $this->getMappedField($leftcol); + if ($mapped !== null) { + $this->requireColumn($leftcol); + $leftcol = $mapped; + } + + $joinOn = sprintf( + $this->customVarsJoinTemplate, + $leftcol, + $alias, + $this->db->quote($name) + ); + + $this->select->joinLeft( + array($alias => $this->prefix . 'customvariablestatus'), + $joinOn, + array() + ); + + return $this; + } +} diff --git a/library/Businessprocess/Monitoring/Backend/Ido/Query/HostStatusQuery.php b/library/Businessprocess/Monitoring/Backend/Ido/Query/HostStatusQuery.php new file mode 100644 index 0000000..e6ea238 --- /dev/null +++ b/library/Businessprocess/Monitoring/Backend/Ido/Query/HostStatusQuery.php @@ -0,0 +1,8 @@ +query = new HostStatusQuery($connection->getResource(), $columns); + } +} diff --git a/library/Businessprocess/Monitoring/DataView/ServiceStatus.php b/library/Businessprocess/Monitoring/DataView/ServiceStatus.php new file mode 100644 index 0000000..f3a9c3c --- /dev/null +++ b/library/Businessprocess/Monitoring/DataView/ServiceStatus.php @@ -0,0 +1,16 @@ +query = new ServiceStatusQuery($connection->getResource(), $columns); + } +} diff --git a/library/Businessprocess/ServiceNode.php b/library/Businessprocess/ServiceNode.php index cba6acf..c80b984 100644 --- a/library/Businessprocess/ServiceNode.php +++ b/library/Businessprocess/ServiceNode.php @@ -3,9 +3,12 @@ namespace Icinga\Module\Businessprocess; use Icinga\Module\Businessprocess\Web\Url; +use ipl\I18n\Translation; class ServiceNode extends MonitoredNode { + use Translation; + protected $hostname; /** @var string Alias of the host */ @@ -69,7 +72,11 @@ class ServiceNode extends MonitoredNode return null; } - return $this->getHostAlias() . ': ' . $this->alias; + return sprintf( + $this->translate('%s on %s', ' on '), + $this->alias, + $this->getHostAlias() + ); } public function getUrl() diff --git a/library/Businessprocess/Web/Form/Element/IplStateOverrides.php b/library/Businessprocess/Web/Form/Element/IplStateOverrides.php new file mode 100644 index 0000000..5b9ea16 --- /dev/null +++ b/library/Businessprocess/Web/Form/Element/IplStateOverrides.php @@ -0,0 +1,75 @@ +options = $options; + + return $this; + } + + /** + * Get the options to show + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + public function getValues() + { + $cleanedValue = parent::getValues(); + + if (! empty($cleanedValue)) { + foreach ($cleanedValue as $from => $to) { + if ((int) $from === (int) $to) { + unset($cleanedValue[$from]); + } + } + } + + return $cleanedValue; + } + + protected function assemble() + { + $states = $this->getOptions(); + foreach ($states as $state => $label) { + if ($state === 0) { + continue; + } + + $this->addElement('select', $state, [ + 'label' => $label, + 'value' => $state, + 'options' => [$state => $this->translate('Keep actual state')] + $states + ]); + } + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $this->getAttributes() + ->registerAttributeCallback('options', null, [$this, 'setOptions']); + } +} diff --git a/library/Businessprocess/Web/Form/Element/StateOverrides.php b/library/Businessprocess/Web/Form/Element/StateOverrides.php deleted file mode 100644 index c2216c0..0000000 --- a/library/Businessprocess/Web/Form/Element/StateOverrides.php +++ /dev/null @@ -1,55 +0,0 @@ -states = $states; - - return $this; - } - - /** - * Get the overridable states - * - * @return array - */ - public function getStates() - { - return $this->states; - } - - public function init() - { - $this->setIsArray(true); - } - - public function setValue($value) - { - $cleanedValue = []; - - if (! empty($value)) { - foreach ($value as $from => $to) { - if ((int) $from !== (int) $to) { - $cleanedValue[$from] = $to; - } - } - } - - return parent::setValue($cleanedValue); - } -} diff --git a/library/Businessprocess/Web/Form/Validator/HostServiceTermValidator.php b/library/Businessprocess/Web/Form/Validator/HostServiceTermValidator.php new file mode 100644 index 0000000..b141206 --- /dev/null +++ b/library/Businessprocess/Web/Form/Validator/HostServiceTermValidator.php @@ -0,0 +1,90 @@ +parent = $parent; + + return $this; + } + + public function isValid($terms) + { + if ($this->parent === null) { + throw new LogicException('Missing parent process. Cannot validate terms.'); + } + + if (! is_array($terms)) { + $terms = [$terms]; + } + + $testConfig = new BpConfig(); + + foreach ($terms as $term) { + /** @var Term $term */ + [$hostName, $serviceName] = BpConfig::splitNodeName($term->getSearchValue()); + if ($serviceName !== null && $serviceName !== 'Hoststatus') { + $node = $testConfig->createService($hostName, $serviceName); + } else { + $node = $testConfig->createHost($hostName); + if ($serviceName === null) { + $term->setSearchValue(BpConfig::joinNodeName($hostName, 'Hoststatus')); + } + } + + if ($this->parent->hasChild($term->getSearchValue())) { + $term->setMessage($this->translate('Already defined in this process')); + } else { + $testConfig->getNode('__unbound__') + ->addChild($node); + } + } + + if ($this->parent->getBpConfig()->getBackend() instanceof MonitoringBackend) { + MonitoringState::apply($testConfig); + } else { + IcingaDbState::apply($testConfig); + } + + foreach ($terms as $term) { + /** @var Term $term */ + $node = $testConfig->getNode($term->getSearchValue()); + if ($node->isMissing()) { + if ($node instanceof ServiceNode) { + $term->setMessage($this->translate('Service not found')); + } else { + $term->setMessage($this->translate('Host not found')); + } + } else { + $term->setLabel($node->getAlias()); + $term->setClass($node->getObjectClassName()); + } + } + } +} diff --git a/library/Businessprocess/Web/Form/Validator/NoDuplicateChildrenValidator.php b/library/Businessprocess/Web/Form/Validator/NoDuplicateChildrenValidator.php deleted file mode 100644 index 9676de0..0000000 --- a/library/Businessprocess/Web/Form/Validator/NoDuplicateChildrenValidator.php +++ /dev/null @@ -1,57 +0,0 @@ -form = $form; - $this->bp = $bp; - $this->parent = $parent; - - $this->_messageVariables['label'] = 'label'; - $this->_messageTemplates = [ - self::CHILD_FOUND => mt('businessprocess', '%label% is already defined in this process') - ]; - } - - public function isValid($value) - { - if ($this->parent === null) { - $found = $this->bp->hasRootNode($value); - } elseif ($this->form instanceof EditNodeForm && $this->form->getNode()->getName() === $value) { - $found = false; - } else { - $found = $this->parent->hasChild($value); - } - - if (! $found) { - return true; - } - - $this->label = $this->form->getElement('children')->getMultiOptions()[$value]; - $this->_error(self::CHILD_FOUND); - return false; - } -} diff --git a/public/css/module.less b/public/css/module.less index 500a137..f048863 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -869,29 +869,6 @@ form.bp-form { } } - #stateOverrides-element { - display: inline-table; - table-layout: fixed; - border-spacing: .5em; - padding: 0; - - label { - display: table-row; - - span, select { - display: table-cell; - } - - span { - width: 10em; - } - - select { - width: 26em; - } - } - } - fieldset { min-width: 36em; } diff --git a/test/php/library/Businessprocess/ServiceNodeTest.php b/test/php/library/Businessprocess/ServiceNodeTest.php index d56529d..62c1605 100644 --- a/test/php/library/Businessprocess/ServiceNodeTest.php +++ b/test/php/library/Businessprocess/ServiceNodeTest.php @@ -27,7 +27,7 @@ class ServiceNodeTest extends BaseTestCase public function testReturnsCorrectAlias() { $this->assertEquals( - 'localhost: ping <> pong', + 'ping <> pong on localhost', $this->pingOnLocalhost()->getAlias() ); } @@ -36,7 +36,7 @@ class ServiceNodeTest extends BaseTestCase { $this->assertEquals( '' - . 'localhost: ping <> pong', + . 'ping <> pong on localhost', $this->pingOnLocalhost()->getLink()->render() ); }