summaryrefslogtreecommitdiffstats
path: root/library/Director/Web
diff options
context:
space:
mode:
Diffstat (limited to 'library/Director/Web')
-rw-r--r--library/Director/Web/ActionBar/AutomationObjectActionBar.php65
-rw-r--r--library/Director/Web/ActionBar/ChoicesActionBar.php27
-rw-r--r--library/Director/Web/ActionBar/DirectorBaseActionBar.php67
-rw-r--r--library/Director/Web/ActionBar/ObjectsActionBar.php27
-rw-r--r--library/Director/Web/ActionBar/TemplateActionBar.php42
-rw-r--r--library/Director/Web/Controller/ActionController.php253
-rw-r--r--library/Director/Web/Controller/BranchHelper.php76
-rw-r--r--library/Director/Web/Controller/Extension/CoreApi.php46
-rw-r--r--library/Director/Web/Controller/Extension/DirectorDb.php160
-rw-r--r--library/Director/Web/Controller/Extension/ObjectRestrictions.php48
-rw-r--r--library/Director/Web/Controller/Extension/RestApi.php114
-rw-r--r--library/Director/Web/Controller/Extension/SingleObjectApiHandler.php236
-rw-r--r--library/Director/Web/Controller/ObjectController.php733
-rw-r--r--library/Director/Web/Controller/ObjectsController.php548
-rw-r--r--library/Director/Web/Controller/TemplateController.php243
-rw-r--r--library/Director/Web/Form/ClickHereForm.php31
-rw-r--r--library/Director/Web/Form/CloneImportSourceForm.php72
-rw-r--r--library/Director/Web/Form/CloneSyncRuleForm.php76
-rw-r--r--library/Director/Web/Form/CsrfToken.php53
-rw-r--r--library/Director/Web/Form/DbSelectorForm.php85
-rw-r--r--library/Director/Web/Form/Decorator/ViewHelperRaw.php14
-rw-r--r--library/Director/Web/Form/DirectorForm.php58
-rw-r--r--library/Director/Web/Form/DirectorObjectForm.php1734
-rw-r--r--library/Director/Web/Form/Element/Boolean.php90
-rw-r--r--library/Director/Web/Form/Element/DataFilter.php361
-rw-r--r--library/Director/Web/Form/Element/ExtensibleSet.php89
-rw-r--r--library/Director/Web/Form/Element/FormElement.php9
-rw-r--r--library/Director/Web/Form/Element/InstanceSummary.php51
-rw-r--r--library/Director/Web/Form/Element/OptionalYesNo.php22
-rw-r--r--library/Director/Web/Form/Element/SimpleNote.php34
-rw-r--r--library/Director/Web/Form/Element/StoredPassword.php62
-rw-r--r--library/Director/Web/Form/Element/Text.php16
-rw-r--r--library/Director/Web/Form/Element/YesNo.php14
-rw-r--r--library/Director/Web/Form/Filter/QueryColumnsFromSql.php48
-rw-r--r--library/Director/Web/Form/FormLoader.php43
-rw-r--r--library/Director/Web/Form/IcingaObjectFieldLoader.php628
-rw-r--r--library/Director/Web/Form/IconHelper.php89
-rw-r--r--library/Director/Web/Form/IplElement/ExtensibleSetElement.php570
-rw-r--r--library/Director/Web/Form/QuickBaseForm.php177
-rw-r--r--library/Director/Web/Form/QuickForm.php641
-rw-r--r--library/Director/Web/Form/QuickSubForm.php36
-rw-r--r--library/Director/Web/Form/Validate/IsDataListEntry.php55
-rw-r--r--library/Director/Web/Form/Validate/NamePattern.php38
-rw-r--r--library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php196
-rw-r--r--library/Director/Web/ObjectPreview.php182
-rw-r--r--library/Director/Web/SelfService.php311
-rw-r--r--library/Director/Web/Table/ActivityLogTable.php294
-rw-r--r--library/Director/Web/Table/ApplyRulesTable.php240
-rw-r--r--library/Director/Web/Table/BasketSnapshotTable.php125
-rw-r--r--library/Director/Web/Table/BasketTable.php50
-rw-r--r--library/Director/Web/Table/BranchActivityTable.php116
-rw-r--r--library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php78
-rw-r--r--library/Director/Web/Table/ChoicesTable.php65
-rw-r--r--library/Director/Web/Table/ConfigFileDiffTable.php140
-rw-r--r--library/Director/Web/Table/CoreApiFieldsTable.php106
-rw-r--r--library/Director/Web/Table/CoreApiObjectsTable.php60
-rw-r--r--library/Director/Web/Table/CoreApiPrototypesTable.php43
-rw-r--r--library/Director/Web/Table/CustomvarTable.php102
-rw-r--r--library/Director/Web/Table/CustomvarVariantsTable.php125
-rw-r--r--library/Director/Web/Table/DatafieldCategoryTable.php64
-rw-r--r--library/Director/Web/Table/DatafieldTable.php118
-rw-r--r--library/Director/Web/Table/DatalistEntryTable.php73
-rw-r--r--library/Director/Web/Table/DatalistTable.php41
-rw-r--r--library/Director/Web/Table/DbHelper.php67
-rw-r--r--library/Director/Web/Table/Dependency/DependencyInfoTable.php101
-rw-r--r--library/Director/Web/Table/Dependency/Html.php74
-rw-r--r--library/Director/Web/Table/DependencyTemplateUsageTable.php22
-rw-r--r--library/Director/Web/Table/DeploymentLogTable.php90
-rw-r--r--library/Director/Web/Table/FilterableByUsage.php10
-rw-r--r--library/Director/Web/Table/GeneratedConfigFileTable.php120
-rw-r--r--library/Director/Web/Table/GroupMemberTable.php201
-rw-r--r--library/Director/Web/Table/HostTemplateUsageTable.php22
-rw-r--r--library/Director/Web/Table/IcingaAppliedServiceTable.php49
-rw-r--r--library/Director/Web/Table/IcingaCommandArgumentTable.php89
-rw-r--r--library/Director/Web/Table/IcingaHostAppliedForServiceTable.php117
-rw-r--r--library/Director/Web/Table/IcingaHostAppliedServicesTable.php207
-rw-r--r--library/Director/Web/Table/IcingaHostsMatchingFilterTable.php71
-rw-r--r--library/Director/Web/Table/IcingaObjectDatafieldTable.php87
-rw-r--r--library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php67
-rw-r--r--library/Director/Web/Table/IcingaServiceSetHostTable.php64
-rw-r--r--library/Director/Web/Table/IcingaServiceSetServiceTable.php259
-rw-r--r--library/Director/Web/Table/IcingaTimePeriodRangeTable.php61
-rw-r--r--library/Director/Web/Table/ImportedrowsTable.php103
-rw-r--r--library/Director/Web/Table/ImportrunTable.php90
-rw-r--r--library/Director/Web/Table/ImportsourceHookTable.php107
-rw-r--r--library/Director/Web/Table/ImportsourceTable.php63
-rw-r--r--library/Director/Web/Table/JobTable.php82
-rw-r--r--library/Director/Web/Table/NotificationTemplateUsageTable.php22
-rw-r--r--library/Director/Web/Table/ObjectSetTable.php211
-rw-r--r--library/Director/Web/Table/ObjectsTable.php315
-rw-r--r--library/Director/Web/Table/ObjectsTableApiUser.php13
-rw-r--r--library/Director/Web/Table/ObjectsTableCommand.php67
-rw-r--r--library/Director/Web/Table/ObjectsTableEndpoint.php86
-rw-r--r--library/Director/Web/Table/ObjectsTableHost.php40
-rw-r--r--library/Director/Web/Table/ObjectsTableHostTemplateChoice.php27
-rw-r--r--library/Director/Web/Table/ObjectsTableService.php219
-rw-r--r--library/Director/Web/Table/ObjectsTableZone.php13
-rw-r--r--library/Director/Web/Table/PropertymodifierTable.php145
-rw-r--r--library/Director/Web/Table/QuickTable.php547
-rw-r--r--library/Director/Web/Table/ReadOnlyFormAvpTable.php113
-rw-r--r--library/Director/Web/Table/ServiceTemplateUsageTable.php27
-rw-r--r--library/Director/Web/Table/SyncRunTable.php90
-rw-r--r--library/Director/Web/Table/SyncpropertyTable.php97
-rw-r--r--library/Director/Web/Table/SyncruleTable.php67
-rw-r--r--library/Director/Web/Table/TableLoader.php34
-rw-r--r--library/Director/Web/Table/TableWithBranchSupport.php69
-rw-r--r--library/Director/Web/Table/TemplateUsageTable.php157
-rw-r--r--library/Director/Web/Table/TemplatesTable.php156
-rw-r--r--library/Director/Web/Tabs/DataTabs.php34
-rw-r--r--library/Director/Web/Tabs/ImportTabs.php30
-rw-r--r--library/Director/Web/Tabs/ImportsourceTabs.php58
-rw-r--r--library/Director/Web/Tabs/InfraTabs.php49
-rw-r--r--library/Director/Web/Tabs/MainTabs.php85
-rw-r--r--library/Director/Web/Tabs/ObjectTabs.php160
-rw-r--r--library/Director/Web/Tabs/ObjectsTabs.php85
-rw-r--r--library/Director/Web/Tabs/SyncRuleTabs.php54
-rw-r--r--library/Director/Web/Tree/InspectTreeRenderer.php97
-rw-r--r--library/Director/Web/Tree/TemplateTreeRenderer.php91
-rw-r--r--library/Director/Web/Widget/AbstractList.php40
-rw-r--r--library/Director/Web/Widget/ActivityLogInfo.php634
-rw-r--r--library/Director/Web/Widget/AdditionalTableActions.php158
-rw-r--r--library/Director/Web/Widget/BackgroundDaemonDetails.php131
-rw-r--r--library/Director/Web/Widget/BranchedObjectHint.php69
-rw-r--r--library/Director/Web/Widget/BranchedObjectsHint.php27
-rw-r--r--library/Director/Web/Widget/Daemon/BackgroundDaemonState.php57
-rw-r--r--library/Director/Web/Widget/DeployedConfigInfoHeader.php101
-rw-r--r--library/Director/Web/Widget/DeploymentInfo.php169
-rw-r--r--library/Director/Web/Widget/Documentation.php97
-rw-r--r--library/Director/Web/Widget/HealthCheckPluginOutput.php94
-rw-r--r--library/Director/Web/Widget/IcingaConfigDiff.php58
-rw-r--r--library/Director/Web/Widget/IcingaObjectInspection.php254
-rw-r--r--library/Director/Web/Widget/ImportSourceDetails.php83
-rw-r--r--library/Director/Web/Widget/InspectPackages.php174
-rw-r--r--library/Director/Web/Widget/JobDetails.php69
-rw-r--r--library/Director/Web/Widget/ListItem.php26
-rw-r--r--library/Director/Web/Widget/NotInBranchedHint.php23
-rw-r--r--library/Director/Web/Widget/OrderedList.php8
-rw-r--r--library/Director/Web/Widget/ShowConfigFile.php106
-rw-r--r--library/Director/Web/Widget/SyncRunDetails.php129
-rw-r--r--library/Director/Web/Widget/UnorderedList.php8
-rw-r--r--library/Director/Web/Window.php13
141 files changed, 18419 insertions, 0 deletions
diff --git a/library/Director/Web/ActionBar/AutomationObjectActionBar.php b/library/Director/Web/ActionBar/AutomationObjectActionBar.php
new file mode 100644
index 0000000..247677f
--- /dev/null
+++ b/library/Director/Web/ActionBar/AutomationObjectActionBar.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ActionBar;
+use Icinga\Web\Request;
+
+class AutomationObjectActionBar extends ActionBar
+{
+ use TranslationHelper;
+
+ /** @var Request */
+ protected $request;
+
+ protected $label;
+
+ public function __construct(Request $request)
+ {
+ $this->request = $request;
+ }
+
+ protected function assemble()
+ {
+ $request = $this->request;
+ $action = $request->getActionName();
+ $controller = $request->getControllerName();
+ $params = ['id' => $request->getParam('id')];
+ $links = [
+ 'index' => Link::create(
+ $this->translate('Overview'),
+ "director/$controller",
+ $params,
+ ['class' => 'icon-info']
+ ),
+ 'edit' => Link::create(
+ $this->translate('Modify'),
+ "director/$controller/edit",
+ $params,
+ ['class' => 'icon-edit']
+ ),
+ 'clone' => Link::create(
+ $this->translate('Clone'),
+ "director/$controller/clone",
+ $params,
+ ['class' => 'icon-paste']
+ ),
+ /*
+ // TODO: enable once handled in the controller
+ 'export' => Link::create(
+ $this->translate('Download JSON'),
+ $this->request->getUrl()->with('format', 'json'),
+ null,
+ [
+ 'data-base-target' => '_blank',
+ ]
+ )
+ */
+
+ ];
+ unset($links[$action]);
+ $this->add($links);
+ }
+}
diff --git a/library/Director/Web/ActionBar/ChoicesActionBar.php b/library/Director/Web/ActionBar/ChoicesActionBar.php
new file mode 100644
index 0000000..7b59d2c
--- /dev/null
+++ b/library/Director/Web/ActionBar/ChoicesActionBar.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+
+class ChoicesActionBar extends DirectorBaseActionBar
+{
+ protected function assemble()
+ {
+ $type = $this->type;
+ $this->add(
+ $this->getBackToDashboardLink()
+ )->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/templatechoice/$type",
+ ['type' => 'object'],
+ [
+ 'title' => $this->translate('Create a new template choice'),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Director/Web/ActionBar/DirectorBaseActionBar.php b/library/Director/Web/ActionBar/DirectorBaseActionBar.php
new file mode 100644
index 0000000..8612a0d
--- /dev/null
+++ b/library/Director/Web/ActionBar/DirectorBaseActionBar.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use Icinga\Module\Director\Dashboard\Dashboard;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ActionBar;
+use gipfl\IcingaWeb2\Url;
+
+class DirectorBaseActionBar extends ActionBar
+{
+ use TranslationHelper;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var string */
+ protected $type;
+
+ public function __construct($type, Url $url)
+ {
+ $this->type = $type;
+ $this->url = $url;
+ }
+
+ protected function getBackToDashboardLink()
+ {
+ $name = $this->getPluralBaseType();
+ if (! Dashboard::exists($name)) {
+ return null;
+ }
+
+ return Link::create(
+ $this->translate('back'),
+ 'director/dashboard',
+ ['name' => $name],
+ [
+ 'title' => sprintf(
+ $this->translate('Go back to "%s" Dashboard'),
+ $this->translate(ucfirst($this->type))
+ ),
+ 'class' => 'icon-left-big',
+ 'data-base-target' => '_main'
+ ]
+ );
+ }
+
+ protected function getBaseType()
+ {
+ if (substr($this->type, -5) === 'Group') {
+ return substr($this->type, 0, -5);
+ } else {
+ return $this->type;
+ }
+ }
+
+ protected function getPluralType()
+ {
+ return $this->type . 's';
+ }
+
+ protected function getPluralBaseType()
+ {
+ return $this->getBaseType() . 's';
+ }
+}
diff --git a/library/Director/Web/ActionBar/ObjectsActionBar.php b/library/Director/Web/ActionBar/ObjectsActionBar.php
new file mode 100644
index 0000000..5f86949
--- /dev/null
+++ b/library/Director/Web/ActionBar/ObjectsActionBar.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+
+class ObjectsActionBar extends DirectorBaseActionBar
+{
+ protected function assemble()
+ {
+ $type = $this->type;
+ $this->add(
+ $this->getBackToDashboardLink()
+ )->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/$type/add",
+ ['type' => 'object'],
+ [
+ 'title' => $this->translate('Create a new object'),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Director/Web/ActionBar/TemplateActionBar.php b/library/Director/Web/ActionBar/TemplateActionBar.php
new file mode 100644
index 0000000..53e65ed
--- /dev/null
+++ b/library/Director/Web/ActionBar/TemplateActionBar.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+
+class TemplateActionBar extends DirectorBaseActionBar
+{
+ protected function assemble()
+ {
+ $type = str_replace('_', '-', $this->type);
+ $plType = preg_replace('/cys$/', 'cies', $type . 's');
+ $renderTree = $this->url->getParam('render') === 'tree';
+ $renderParams = $renderTree ? null : ['render' => 'tree'];
+ $this->add(
+ $this->getBackToDashboardLink()
+ )->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/$type/add",
+ ['type' => 'template'],
+ [
+ 'title' => $this->translate('Create a new Template'),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ )->add(
+ Link::create(
+ $renderTree ? $this->translate('Table') : $this->translate('Tree'),
+ "director/$plType/templates",
+ $renderParams,
+ [
+ 'class' => 'icon-' . ($renderTree ? 'doc-text' : 'sitemap'),
+ 'title' => $renderTree
+ ? $this->translate('Switch to Tree view')
+ : $this->translate('Switch to Table view')
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Director/Web/Controller/ActionController.php b/library/Director/Web/Controller/ActionController.php
new file mode 100644
index 0000000..6282a16
--- /dev/null
+++ b/library/Director/Web/Controller/ActionController.php
@@ -0,0 +1,253 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\Translation\StaticTranslator;
+use Icinga\Application\Benchmark;
+use Icinga\Data\Paginatable;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Monitoring;
+use Icinga\Module\Director\Web\Controller\Extension\CoreApi;
+use Icinga\Module\Director\Web\Controller\Extension\DirectorDb;
+use Icinga\Module\Director\Web\Controller\Extension\RestApi;
+use Icinga\Module\Director\Web\Window;
+use Icinga\Security\SecurityException;
+use Icinga\Web\Controller;
+use Icinga\Web\UrlParams;
+use InvalidArgumentException;
+use gipfl\IcingaWeb2\Translator;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+use gipfl\IcingaWeb2\Controller\Extension\ControlsAndContentHelper;
+use gipfl\IcingaWeb2\Zf1\SimpleViewRenderer;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ServerRequestInterface;
+
+abstract class ActionController extends Controller implements ControlsAndContent
+{
+ use DirectorDb;
+ use CoreApi;
+ use RestApi;
+ use ControlsAndContentHelper;
+
+ protected $isApified = false;
+
+ /** @var UrlParams Hint for IDE, somehow does not work in web */
+ protected $params;
+
+ /** @var Monitoring */
+ private $monitoring;
+
+ /**
+ * @throws SecurityException
+ * @throws \Icinga\Exception\AuthenticationException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function init()
+ {
+ if (! $this->getRequest()->isApiRequest()
+ && $this->Config()->get('frontend', 'disabled', 'no') === 'yes'
+ ) {
+ throw new NotFoundError('Not found');
+ }
+ $this->initializeTranslator();
+ Benchmark::measure('Director base Controller init()');
+ $this->checkForRestApiRequest();
+ $this->checkDirectorPermissions();
+ $this->checkSpecialDirectorPermissions();
+ }
+
+ protected function initializeTranslator()
+ {
+ StaticTranslator::set(new Translator('director'));
+ }
+
+ public function getAuth()
+ {
+ return $this->Auth();
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ * @return Window
+ */
+ public function Window()
+ {
+ // @codingStandardsIgnoreEnd
+ if ($this->window === null) {
+ $this->window = new Window(
+ $this->_request->getHeader('X-Icinga-WindowId', Window::UNDEFINED)
+ );
+ }
+ return $this->window;
+ }
+
+ /**
+ * @throws SecurityException
+ */
+ protected function checkDirectorPermissions()
+ {
+ $this->assertPermission('director/admin');
+ }
+
+ /**
+ * @throws SecurityException
+ */
+ protected function checkSpecialDirectorPermissions()
+ {
+ if ($this->params->get('format') === 'sql') {
+ $this->assertPermission('director/showsql');
+ }
+ }
+
+ /**
+ * Assert that the current user has one of the given permission
+ *
+ * @param array $permissions Permission name list
+ *
+ * @return $this
+ * @throws SecurityException If the current user lacks the given permission
+ */
+ protected function assertOneOfPermissions($permissions)
+ {
+ $auth = $this->Auth();
+
+ foreach ($permissions as $permission) {
+ if ($auth->hasPermission($permission)) {
+ return $this;
+ }
+ }
+
+ throw new SecurityException(
+ 'Got none of the following permissions: %s',
+ implode(', ', $permissions)
+ );
+ }
+
+ /**
+ * @param int $interval
+ * @return $this
+ */
+ public function setAutorefreshInterval($interval)
+ {
+ if (! $this->getRequest()->isApiRequest()) {
+ try {
+ parent::setAutorefreshInterval($interval);
+ } catch (ProgrammingError $e) {
+ throw new InvalidArgumentException($e->getMessage());
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return ServerRequestInterface
+ */
+ protected function getServerRequest()
+ {
+ return ServerRequest::fromGlobals();
+ }
+
+ protected function applyPaginationLimits(Paginatable $paginatable, $limit = 25, $offset = null)
+ {
+ $limit = $this->params->get('limit', $limit);
+ $page = $this->params->get('page', $offset);
+
+ $paginatable->limit($limit, $page > 0 ? ($page - 1) * $limit : 0);
+
+ return $paginatable;
+ }
+
+ protected function addAddLink($title, $url, $urlParams = null, $target = '_next')
+ {
+ $this->actions()->add(Link::create(
+ $this->translate('Add'),
+ $url,
+ $urlParams,
+ [
+ 'class' => 'icon-plus',
+ 'title' => $title,
+ 'data-base-target' => $target
+ ]
+ ));
+
+ return $this;
+ }
+
+ protected function addBackLink($url, $urlParams = null)
+ {
+ $this->actions()->add(new Link(
+ $this->translate('back'),
+ $url,
+ $urlParams,
+ ['class' => 'icon-left-big']
+ ));
+
+ return $this;
+ }
+
+ protected function sendUnsupportedMethod()
+ {
+ $method = strtoupper($this->getRequest()->getMethod()) ;
+ $response = $this->getResponse();
+ $this->sendJsonError($response, sprintf(
+ 'Method %s is not supported',
+ $method
+ ), 422); // TODO: check response code
+ }
+
+ /**
+ * @param string $permission
+ * @return $this
+ * @throws SecurityException
+ */
+ public function assertPermission($permission)
+ {
+ parent::assertPermission($permission);
+ return $this;
+ }
+
+ public function postDispatch()
+ {
+ Benchmark::measure('Director postDispatch');
+ if ($this->view->content || $this->view->controls) {
+ $viewRenderer = new SimpleViewRenderer();
+ $viewRenderer->replaceZendViewRenderer();
+ $this->view = $viewRenderer->view;
+ // Hint -> $this->view->compact is the only way since v2.8.0
+ if ($this->view->compact || $this->getOriginalUrl()->getParam('view') === 'compact') {
+ if ($this->view->controls) {
+ $this->controls()->getAttributes()->add('style', 'display: none;');
+ }
+ }
+ } else {
+ $viewRenderer = null;
+ }
+
+ $cType = $this->getResponse()->getHeader('Content-Type', true);
+ if ($this->getRequest()->isApiRequest() || ($cType !== null && $cType !== 'text/html')) {
+ $this->_helper->layout()->disableLayout();
+ if ($viewRenderer) {
+ $viewRenderer->disable();
+ } else {
+ $this->_helper->viewRenderer->setNoRender(true);
+ }
+ }
+
+ parent::postDispatch(); // TODO: Change the autogenerated stub
+ }
+
+ /**
+ * @return Monitoring
+ */
+ protected function monitoring()
+ {
+ if ($this->monitoring === null) {
+ $this->monitoring = new Monitoring;
+ }
+
+ return $this->monitoring;
+ }
+}
diff --git a/library/Director/Web/Controller/BranchHelper.php b/library/Director/Web/Controller/BranchHelper.php
new file mode 100644
index 0000000..ac2a480
--- /dev/null
+++ b/library/Director/Web/Controller/BranchHelper.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchStore;
+use Icinga\Module\Director\Db\Branch\BranchSupport;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Widget\NotInBranchedHint;
+
+trait BranchHelper
+{
+ /** @var Branch */
+ protected $branch;
+
+ /** @var BranchStore */
+ protected $branchStore;
+
+ /**
+ * @return false|\Ramsey\Uuid\UuidInterface
+ */
+ protected function getBranchUuid()
+ {
+ return $this->getBranch()->getUuid();
+ }
+
+ protected function getBranch()
+ {
+ if ($this->branch === null) {
+ /** @var ActionController $this */
+ $this->branch = Branch::forRequest($this->getRequest(), $this->getBranchStore(), $this->Auth());
+ }
+
+ return $this->branch;
+ }
+
+ /**
+ * @return BranchStore
+ */
+ protected function getBranchStore()
+ {
+ if ($this->branchStore === null) {
+ $this->branchStore = new BranchStore($this->db());
+ }
+
+ return $this->branchStore;
+ }
+
+ protected function hasBranch()
+ {
+ return $this->getBranchUuid() !== null;
+ }
+
+ protected function enableStaticObjectLoader($table)
+ {
+ if (BranchSupport::existsForTableName($table)) {
+ IcingaObject::setDbObjectStore(new DbObjectStore($this->db(), $this->getBranch()));
+ }
+ }
+
+ /**
+ * @param string $subject
+ * @return bool
+ */
+ protected function showNotInBranch($subject)
+ {
+ if ($this->getBranch()->isBranch()) {
+ $this->content()->add(new NotInBranchedHint($subject, $this->getBranch(), $this->Auth()));
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/CoreApi.php b/library/Director/Web/Controller/Extension/CoreApi.php
new file mode 100644
index 0000000..75cba50
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/CoreApi.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use Icinga\Module\Director\Core\CoreApi as Api;
+
+trait CoreApi
+{
+ /** @var Api */
+ private $api;
+
+ /**
+ * @return Api|null
+ */
+ public function getApiIfAvailable()
+ {
+ if ($this->api === null) {
+ if ($this->db()->hasDeploymentEndpoint()) {
+ $endpoint = $this->db()->getDeploymentEndpoint();
+ $this->api = $endpoint->api();
+ }
+ }
+
+ return $this->api;
+ }
+
+ /**
+ * @param string $endpointName
+ * @return Api
+ */
+ public function api($endpointName = null)
+ {
+ if ($this->api === null) {
+ if ($endpointName === null) {
+ $endpoint = $this->db()->getDeploymentEndpoint();
+ } else {
+ $endpoint = IcingaEndpoint::load($endpointName, $this->db());
+ }
+
+ $this->api = $endpoint->api();
+ }
+
+ return $this->api;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/DirectorDb.php b/library/Director/Web/Controller/Extension/DirectorDb.php
new file mode 100644
index 0000000..03bec81
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/DirectorDb.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Web\Controller\ActionController;
+use Icinga\Module\Director\Web\Window;
+use RuntimeException;
+
+trait DirectorDb
+{
+ /** @var Db */
+ private $db;
+
+ protected function getDbResourceName()
+ {
+ if ($name = $this->getDbResourceNameFromRequest()) {
+ return $name;
+ } elseif ($name = $this->getPreferredDbResourceName()) {
+ return $name;
+ } else {
+ return $this->getFirstDbResourceName();
+ }
+ }
+
+ protected function getDbResourceNameFromRequest()
+ {
+ $param = 'dbResourceName';
+ // We shouldn't access _POST and _GET. However, this trait is used
+ // in various places - and our Request is going to be replaced anyways.
+ // So, let's not over-engineer things, this is quick & dirty:
+ if (isset($_POST[$param])) {
+ $name = $_POST[$param];
+ } elseif (isset($_GET[$param])) {
+ $name = $_GET[$param];
+ } else {
+ return null;
+ }
+
+ if (in_array($name, $this->listAllowedDbResourceNames())) {
+ return $name;
+ } else {
+ return null;
+ }
+ }
+
+ protected function getPreferredDbResourceName()
+ {
+ return $this->getWindowSessionValue('db_resource');
+ }
+
+ protected function getFirstDbResourceName()
+ {
+ $names = $this->listAllowedDbResourceNames();
+ if (empty($names)) {
+ return null;
+ } else {
+ return array_shift($names);
+ }
+ }
+
+ protected function listAllowedDbResourceNames()
+ {
+ /** @var \Icinga\Authentication\Auth $auth */
+ $auth = $this->Auth();
+
+ $available = $this->listAvailableDbResourceNames();
+ if ($resourceNames = $auth->getRestrictions('director/db_resource')) {
+ $names = [];
+ foreach ($resourceNames as $rNames) {
+ foreach ($this->splitList($rNames) as $name) {
+ if (array_key_exists($name, $available)) {
+ $names[] = $name;
+ }
+ }
+ }
+
+ return $names;
+ } else {
+ return $available;
+ }
+ }
+
+ /**
+ * @param string $string
+ * @return array
+ */
+ protected function splitList($string)
+ {
+ return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ protected function isMultiDbSetup()
+ {
+ return count($this->listAvailableDbResourceNames()) > 1;
+ }
+
+ /**
+ * @return array
+ */
+ protected function listAvailableDbResourceNames()
+ {
+ /** @var \Icinga\Application\Config $config */
+ $config = $this->Config();
+ $resources = $config->get('db', 'resources');
+ if ($resources === null) {
+ $resource = $config->get('db', 'resource');
+ if ($resource === null) {
+ return [];
+ } else {
+ return [$resource => $resource];
+ }
+ } else {
+ $resources = $this->splitList($resources);
+ $resources = array_combine($resources, $resources);
+ // natsort doesn't work!?
+ ksort($resources, SORT_NATURAL);
+ if ($resource = $config->get('db', 'resource')) {
+ unset($resources[$resource]);
+ $resources = [$resource => $resource] + $resources;
+ }
+
+ return $resources;
+ }
+ }
+
+ protected function getWindowSessionValue($value, $default = null)
+ {
+ /** @var Window $window */
+ $window = $this->Window();
+ /** @var \Icinga\Web\Session\SessionNamespace $session */
+ $session = $window->getSessionNamespace('director');
+
+ return $session->get($value, $default);
+ }
+
+ /**
+ *
+ * @return Db
+ */
+ public function db()
+ {
+ if ($this->db === null) {
+ $resourceName = $this->getDbResourceName();
+ if ($resourceName) {
+ $this->db = Db::fromResourceName($resourceName);
+ } elseif ($this instanceof ActionController) {
+ if ($this->getRequest()->isApiRequest()) {
+ throw new RuntimeException('Icinga Director is not correctly configured');
+ } else {
+ $this->redirectNow('director');
+ }
+ } else {
+ throw new RuntimeException('Icinga Director is not correctly configured');
+ }
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/ObjectRestrictions.php b/library/Director/Web/Controller/Extension/ObjectRestrictions.php
new file mode 100644
index 0000000..bedb3f1
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/ObjectRestrictions.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Restriction\HostgroupRestriction;
+use Icinga\Module\Director\Restriction\ObjectRestriction;
+
+trait ObjectRestrictions
+{
+ /** @var ObjectRestriction[] */
+ private $objectRestrictions;
+
+ /**
+ * @return ObjectRestriction[]
+ */
+ public function getObjectRestrictions()
+ {
+ if ($this->objectRestrictions === null) {
+ $this->objectRestrictions = $this->loadObjectRestrictions($this->db(), $this->Auth());
+ }
+
+ return $this->objectRestrictions;
+ }
+
+ /**
+ * @return ObjectRestriction[]
+ */
+ protected function loadObjectRestrictions(Db $db, Auth $auth)
+ {
+ return [
+ new HostgroupRestriction($db, $auth)
+ ];
+ }
+
+ public function allowsObject(IcingaObject $object)
+ {
+ foreach ($this->getObjectRestrictions() as $restriction) {
+ if (! $restriction->allows($object)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/RestApi.php b/library/Director/Web/Controller/Extension/RestApi.php
new file mode 100644
index 0000000..3158f49
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/RestApi.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Exception\JsonException;
+use Icinga\Web\Response;
+use InvalidArgumentException;
+use Zend_Controller_Response_Exception;
+
+trait RestApi
+{
+ protected function isApified()
+ {
+ if (property_exists($this, 'isApified')) {
+ return $this->isApified;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function sendNotFoundForRestApi()
+ {
+ /** @var \Icinga\Web\Request $request */
+ $request = $this->getRequest();
+ if ($request->isApiRequest()) {
+ $this->sendJsonError($this->getResponse(), 'Not found', 404);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function sendNotFoundUnlessRestApi()
+ {
+ /** @var \Icinga\Web\Request $request */
+ $request = $this->getRequest();
+ if ($request->isApiRequest()) {
+ return false;
+ } else {
+ $this->sendJsonError($this->getResponse(), 'Not found', 404);
+ return true;
+ }
+ }
+
+ /**
+ * @throws AuthenticationException
+ */
+ protected function assertApiPermission()
+ {
+ if (! $this->hasPermission('director/api')) {
+ throw new AuthenticationException('You are not allowed to access this API');
+ }
+ }
+
+ /**
+ * @throws AuthenticationException
+ * @throws NotFoundError
+ */
+ protected function checkForRestApiRequest()
+ {
+ /** @var \Icinga\Web\Request $request */
+ $request = $this->getRequest();
+ if ($request->isApiRequest()) {
+ $this->assertApiPermission();
+ if (! $this->isApified()) {
+ throw new NotFoundError('No such API endpoint found');
+ }
+ }
+ }
+
+ /**
+ * @param Response $response
+ * @param $object
+ */
+ protected function sendJson(Response $response, $object)
+ {
+ $response->setHeader('Content-Type', 'application/json', true);
+ echo json_encode($object, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
+ }
+
+ /**
+ * @param Response $response
+ * @param string $message
+ * @param int|null $code
+ */
+ protected function sendJsonError(Response $response, $message, $code = null)
+ {
+ if ($code !== null) {
+ try {
+ $response->setHttpResponseCode((int) $code);
+ } catch (Zend_Controller_Response_Exception $e) {
+ throw new InvalidArgumentException($e->getMessage(), 0, $e);
+ }
+ }
+
+ $this->sendJson($response, (object) ['error' => $message]);
+ }
+
+ /**
+ * @return string
+ */
+ protected function getLastJsonError()
+ {
+ return JsonException::getJsonErrorMessage(json_last_error());
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php
new file mode 100644
index 0000000..bc51548
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Forms\IcingaDeleteObjectForm;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+
+class SingleObjectApiHandler
+{
+ use DirectorDb;
+
+ /** @var IcingaObject */
+ private $object;
+
+ /** @var string */
+ private $type;
+
+ /** @var Request */
+ private $request;
+
+ /** @var Response */
+ private $response;
+
+ /** @var \Icinga\Web\UrlParams */
+ private $params;
+
+ public function __construct($type, Request $request, Response $response)
+ {
+ $this->type = $type;
+ $this->request = $request;
+ $this->response = $response;
+ $this->params = $request->getUrl()->getParams();
+ }
+
+ public function runFailSafe()
+ {
+ try {
+ $this->loadObject();
+ $this->run();
+ } catch (NotFoundError $e) {
+ $this->sendJsonError($e->getMessage(), 404);
+ } catch (Exception $e) {
+ $response = $this->response;
+ if ($response->getHttpResponseCode() === 200) {
+ $response->setHttpResponseCode(500);
+ }
+
+ $this->sendJsonError($e->getMessage());
+ }
+ }
+
+ protected function retrieveObject()
+ {
+ $this->requireObject();
+ $this->sendJson(
+ $this->object->toPlainObject(
+ $this->params->shift('resolved'),
+ ! $this->params->shift('withNull'),
+ $this->params->shift('properties')
+ )
+ );
+ }
+
+ protected function deleteObject()
+ {
+ $this->requireObject();
+ $obj = $this->object->toPlainObject(false, true);
+ $form = new IcingaDeleteObjectForm();
+ $form->setObject($this->object)
+ ->setRequest($this->request)
+ ->onSuccess();
+
+ $this->sendJson($obj);
+ }
+
+ protected function storeObject()
+ {
+ $data = json_decode($this->request->getRawBody());
+
+ if ($data === null) {
+ $this->response->setHttpResponseCode(400);
+ throw new IcingaException(
+ 'Invalid JSON: %s' . $this->request->getRawBody(),
+ $this->getLastJsonError()
+ );
+ } else {
+ $data = (array) $data;
+ }
+
+ if ($object = $this->object) {
+ if ($this->request->getMethod() === 'POST') {
+ $object->setProperties($data);
+ } else {
+ $data = array_merge([
+ 'object_type' => $object->object_type,
+ 'object_name' => $object->object_name
+ ], $data);
+ $object->replaceWith(
+ IcingaObject::createByType($this->type, $data, $db)
+ );
+ }
+ } else {
+ $object = IcingaObject::createByType($this->type, $data, $db);
+ }
+
+ if ($object->hasBeenModified()) {
+ $status = $object->hasBeenLoadedFromDb() ? 200 : 201;
+ $object->store();
+ $this->response->setHttpResponseCode($status);
+ } else {
+ $this->response->setHttpResponseCode(304);
+ }
+
+ $this->sendJson($object->toPlainObject(false, true));
+ }
+
+ public function run()
+ {
+ switch ($this->request->getMethod()) {
+ case 'DELETE':
+ $this->deleteObject();
+ break;
+
+ case 'POST':
+ case 'PUT':
+ $this->storeObject();
+ break;
+
+ case 'GET':
+ $this->retrieveObject();
+ break;
+
+ default:
+ $this->response->setHttpResponseCode(400);
+ throw new IcingaException(
+ 'Unsupported method: %s',
+ $this->request->getMethod()
+ );
+ }
+ }
+
+ protected function requireObject()
+ {
+ if (! $this->object) {
+ $this->response->setHttpResponseCode(404);
+ if (! $this->params->get('name')) {
+ throw new NotFoundError('You need to pass a "name" parameter to access a specific object');
+ } else {
+ throw new NotFoundError('No such object available');
+ }
+ }
+ }
+
+ // TODO: just return json_last_error_msg() for PHP >= 5.5.0
+ protected function getLastJsonError()
+ {
+ switch (json_last_error()) {
+ case JSON_ERROR_DEPTH:
+ return 'The maximum stack depth has been exceeded';
+ case JSON_ERROR_CTRL_CHAR:
+ return 'Control character error, possibly incorrectly encoded';
+ case JSON_ERROR_STATE_MISMATCH:
+ return 'Invalid or malformed JSON';
+ case JSON_ERROR_SYNTAX:
+ return 'Syntax error';
+ case JSON_ERROR_UTF8:
+ return 'Malformed UTF-8 characters, possibly incorrectly encoded';
+ default:
+ return 'An error occured when parsing a JSON string';
+ }
+ }
+
+ protected function sendJson($object)
+ {
+ $this->response->setHeader('Content-Type', 'application/json', true);
+ $this->_helper->layout()->disableLayout();
+ $this->_helper->viewRenderer->setNoRender(true);
+ echo json_encode($object, JSON_PRETTY_PRINT) . "\n";
+ }
+
+ protected function sendJsonError($message, $code = null)
+ {
+ $response = $this->response;
+
+ if ($code !== null) {
+ $response->setHttpResponseCode((int) $code);
+ }
+
+ $this->sendJson((object) ['error' => $message]);
+ }
+
+ protected function loadObject()
+ {
+ if ($this->object === null) {
+ if ($name = $this->params->get('name')) {
+ $this->object = IcingaObject::loadByType(
+ $this->type,
+ $name,
+ $this->db()
+ );
+
+ if (! $this->allowsObject($this->object)) {
+ $this->object = null;
+ throw new NotFoundError('No such object available');
+ }
+ } elseif ($id = $this->params->get('id')) {
+ $this->object = IcingaObject::loadByType(
+ $this->type,
+ (int) $id,
+ $this->db()
+ );
+ } elseif ($this->request->isApiRequest()) {
+ if ($this->request->isGet()) {
+ $this->response->setHttpResponseCode(422);
+
+ throw new InvalidPropertyException(
+ 'Cannot load object, missing parameters'
+ );
+ }
+ }
+ }
+
+ return $this->object;
+ }
+
+ protected function allowsObject(IcingaObject $object)
+ {
+ return true;
+ }
+}
diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php
new file mode 100644
index 0000000..0c06937
--- /dev/null
+++ b/library/Director/Web/Controller/ObjectController.php
@@ -0,0 +1,733 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchedObject;
+use Icinga\Module\Director\Db\Branch\UuidLookup;
+use Icinga\Module\Director\Deployment\DeploymentInfo;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Forms\DeploymentLinkForm;
+use Icinga\Module\Director\Forms\IcingaCloneObjectForm;
+use Icinga\Module\Director\Forms\IcingaObjectFieldForm;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaObjectGroup;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Icinga\Module\Director\RestApi\IcingaObjectHandler;
+use Icinga\Module\Director\Web\Controller\Extension\ObjectRestrictions;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\ObjectPreview;
+use Icinga\Module\Director\Web\Table\ActivityLogTable;
+use Icinga\Module\Director\Web\Table\BranchActivityTable;
+use Icinga\Module\Director\Web\Table\GroupMemberTable;
+use Icinga\Module\Director\Web\Table\IcingaObjectDatafieldTable;
+use Icinga\Module\Director\Web\Tabs\ObjectTabs;
+use Icinga\Module\Director\Web\Widget\BranchedObjectHint;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Html;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+
+abstract class ObjectController extends ActionController
+{
+ use ObjectRestrictions;
+ use BranchHelper;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var bool This controller handles REST API requests */
+ protected $isApified = true;
+
+ /** @var array Allowed object types we are allowed to edit anyways */
+ protected $allowedExternals = array(
+ 'apiuser',
+ 'endpoint'
+ );
+
+ protected $type;
+
+ /** @var string|null */
+ protected $objectBaseUrl;
+
+ public function init()
+ {
+ parent::init();
+ $this->enableStaticObjectLoader($this->getTableName());
+ if ($this->getRequest()->isApiRequest()) {
+ $this->initializeRestApi();
+ } else {
+ $this->initializeWebRequest();
+ }
+ }
+
+ protected function initializeRestApi()
+ {
+ $handler = new IcingaObjectHandler($this->getRequest(), $this->getResponse(), $this->db());
+ try {
+ $this->loadOptionalObject();
+ } catch (NotFoundError $e) {
+ // Silently ignore the error, the handler will complain
+ $handler->sendJsonError($e, 404);
+ // TODO: nice shutdown
+ exit;
+ }
+
+ $handler->setApi($this->api());
+ if ($this->object) {
+ $handler->setObject($this->object);
+ }
+ $handler->dispatch();
+ // Hint: also here, hard exit. There is too much magic going on.
+ // Letting this bubble up smoothly would be "correct", but proved
+ // to be too fragile. Web 2, all kinds of pre/postDispatch magic,
+ // different view renderers - hard exit is the only safe bet right
+ // now.
+ exit;
+ }
+
+ protected function initializeWebRequest()
+ {
+ $this->loadOptionalObject();
+ if ($this->getRequest()->getActionName() === 'add') {
+ $this->addSingleTab(
+ sprintf($this->translate('Add %s'), ucfirst($this->getType())),
+ null,
+ 'add'
+ );
+ } else {
+ $this->tabs(new ObjectTabs(
+ $this->getRequest()->getControllerName(),
+ $this->getAuth(),
+ $this->object
+ ));
+ }
+ if ($this->object !== null) {
+ $this->addDeploymentLink();
+ }
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function indexAction()
+ {
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->redirectToPreviewForExternals()
+ ->editAction();
+ }
+ }
+
+ public function addAction()
+ {
+ $this->tabs()->activate('add');
+ $url = sprintf('director/%ss', $this->getPluralType());
+
+ $imports = $this->params->get('imports');
+ $form = $this->loadObjectForm()
+ ->presetImports($imports)
+ ->setSuccessUrl($url);
+
+ if ($oType = $this->params->get('type', 'object')) {
+ $form->setPreferredObjectType($oType);
+ }
+ if ($oType === 'template') {
+ if ($this->showNotInBranch($this->translate('Creating Templates'))) {
+ $this->addTitle($this->translate('Create a new Template'));
+ return;
+ }
+
+ $this->addTemplate();
+ } else {
+ $this->addObject();
+ }
+ $branch = $this->getBranch();
+ if ($branch->isBranch() && ! $this->getRequest()->isApiRequest()) {
+ $this->content()->add(new BranchedObjectHint($branch, $this->Auth()));
+ }
+
+ $form->handleRequest();
+ $this->content()->add($form);
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function editAction()
+ {
+ $object = $this->requireObject();
+ $this->tabs()->activate('modify');
+ $this->addObjectTitle();
+ // Hint: Service Sets are 'templates' (as long as not being assigned to a host
+ if ($this->getTableName() !== 'icinga_service_set'
+ && $object->isTemplate()
+ && $this->showNotInBranch($this->translate('Modifying Templates'))
+ ) {
+ return;
+ }
+ if ($object->isApplyRule() && $this->showNotInBranch($this->translate('Modifying Apply Rules'))) {
+ return;
+ }
+
+ $this->addObjectForm($object)
+ ->addActionClone()
+ ->addActionUsage()
+ ->addActionBasket();
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function renderAction()
+ {
+ $this->assertTypePermission()
+ ->assertPermission('director/showconfig');
+ $this->tabs()->activate('render');
+ $preview = new ObjectPreview($this->requireObject(), $this->getRequest());
+ if ($this->object->isExternal()) {
+ $this->addActionClone();
+ }
+ $this->addActionBasket();
+ $preview->renderTo($this);
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function cloneAction()
+ {
+ $this->assertTypePermission();
+ $object = $this->requireObject();
+ $this->addTitle($this->translate('Clone: %s'), $object->getObjectName())
+ ->addBackToObjectLink();
+
+ if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Templates'))) {
+ return;
+ }
+
+ if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Apply Rules'))) {
+ return;
+ }
+
+ $form = IcingaCloneObjectForm::load()
+ ->setBranch($this->getBranch())
+ ->setObject($object)
+ ->setObjectBaseUrl($this->getObjectBaseUrl())
+ ->handleRequest();
+
+ if ($object->isExternal()) {
+ $this->tabs()->activate('render');
+ } else {
+ $this->tabs()->activate('modify');
+ }
+ $this->content()->add($form);
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function fieldsAction()
+ {
+ $this->assertPermission('director/admin');
+ $object = $this->requireObject();
+ $type = $this->getType();
+
+ $this->addTitle(
+ $this->translate('Custom fields: %s'),
+ $object->getObjectName()
+ );
+ $this->tabs()->activate('fields');
+ if ($this->showNotInBranch($this->translate('Managing Fields'))) {
+ return;
+ }
+
+ try {
+ $this->addFieldsFormAndTable($object, $type);
+ } catch (NestingError $e) {
+ $this->content()->add(Hint::error($e->getMessage()));
+ }
+ }
+
+ protected function addFieldsFormAndTable($object, $type)
+ {
+ $form = IcingaObjectFieldForm::load()
+ ->setDb($this->db())
+ ->setIcingaObject($object);
+
+ if ($id = $this->params->get('field_id')) {
+ $form->loadObject([
+ "${type}_id" => $object->id,
+ 'datafield_id' => $id
+ ]);
+
+ $this->actions()->add(Link::create(
+ $this->translate('back'),
+ $this->url()->without('field_id'),
+ null,
+ ['class' => 'icon-left-big']
+ ));
+ }
+ $form->handleRequest();
+ $this->content()->add($form);
+ $table = new IcingaObjectDatafieldTable($object);
+ $table->getAttributes()->set('data-base-target', '_self');
+ $table->renderTo($this);
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function historyAction()
+ {
+ $this
+ ->assertTypePermission()
+ ->assertPermission('director/audit')
+ ->setAutorefreshInterval(10)
+ ->tabs()->activate('history');
+
+ $name = $this->requireObject()->getObjectName();
+ $this->addTitle($this->translate('Activity Log: %s'), $name);
+
+ $db = $this->db();
+ $objectTable = $this->object->getTableName();
+ $table = (new ActivityLogTable($db))
+ ->setLastDeployedId($db->getLastDeploymentActivityLogId())
+ ->filterObject($objectTable, $name);
+ if ($host = $this->params->get('host')) {
+ $table->filterHost($host);
+ }
+ $this->showOptionalBranchActivity($table);
+ $table->renderTo($this);
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function membershipAction()
+ {
+ $object = $this->requireObject();
+ if (! $object instanceof IcingaObjectGroup) {
+ throw new NotFoundError('Not Found');
+ }
+
+ $this
+ ->addTitle($this->translate('Group membership: %s'), $object->getObjectName())
+ ->setAutorefreshInterval(15)
+ ->tabs()->activate('membership');
+
+ $type = substr($this->getType(), 0, -5);
+ GroupMemberTable::create($type, $this->db())
+ ->setGroup($object)
+ ->renderTo($this);
+ }
+
+ /**
+ * @return $this
+ * @throws NotFoundError
+ */
+ protected function addObjectTitle()
+ {
+ $object = $this->requireObject();
+ $name = $object->getObjectName();
+ if ($object->isTemplate()) {
+ $this->addTitle($this->translate('Template: %s'), $name);
+ } else {
+ $this->addTitle($name);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws NotFoundError
+ */
+ protected function addActionUsage()
+ {
+ $type = $this->getType();
+ $object = $this->requireObject();
+ if ($object->isTemplate() && $type !== 'serviceSet') {
+ $this->actions()->add([
+ Link::create(
+ $this->translate('Usage'),
+ "director/${type}template/usage",
+ ['name' => $object->getObjectName()],
+ ['class' => 'icon-sitemap']
+ )
+ ]);
+ }
+
+ return $this;
+ }
+
+ protected function addActionClone()
+ {
+ $this->actions()->add(Link::create(
+ $this->translate('Clone'),
+ $this->getObjectBaseUrl() . '/clone',
+ $this->object->getUrlParams(),
+ array('class' => 'icon-paste')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ protected function addActionBasket()
+ {
+ if ($this->hasBasketSupport()) {
+ $object = $this->object;
+ if ($object instanceof ExportInterface) {
+ if ($object instanceof IcingaCommand) {
+ if ($object->isExternal()) {
+ $type = 'ExternalCommand';
+ } elseif ($object->isTemplate()) {
+ $type = 'CommandTemplate';
+ } else {
+ $type = 'Command';
+ }
+ } elseif ($object instanceof IcingaServiceSet) {
+ $type = 'ServiceSet';
+ } elseif ($object->isTemplate()) {
+ $type = ucfirst($this->getType()) . 'Template';
+ } elseif ($object->isGroup()) {
+ $type = ucfirst($this->getType());
+ } else {
+ // Command? Sure?
+ $type = ucfirst($this->getType());
+ }
+ $this->actions()->add(Link::create(
+ $this->translate('Add to Basket'),
+ 'director/basket/add',
+ [
+ 'type' => $type,
+ 'names' => $object->getUniqueIdentifier()
+ ],
+ ['class' => 'icon-tag']
+ ));
+ }
+ }
+
+ return $this;
+ }
+
+ protected function addTemplate()
+ {
+ $this->assertPermission('director/admin');
+ $this->addTitle(
+ $this->translate('Add new Icinga %s template'),
+ $this->getTranslatedType()
+ );
+ }
+
+ protected function addObject()
+ {
+ $this->assertTypePermission();
+ $imports = $this->params->get('imports');
+ if (is_string($imports) && strlen($imports)) {
+ $this->addTitle(
+ $this->translate('Add %s: %s'),
+ $this->getTranslatedType(),
+ $imports
+ );
+ } else {
+ $this->addTitle(
+ $this->translate('Add new Icinga %s'),
+ $this->getTranslatedType()
+ );
+ }
+ }
+
+ protected function redirectToPreviewForExternals()
+ {
+ if ($this->object
+ && $this->object->isExternal()
+ && ! in_array($this->object->getShortTableName(), $this->allowedExternals)
+ ) {
+ $this->redirectNow(
+ $this->getRequest()->getUrl()->setPath(sprintf('director/%s/render', $this->getType()))
+ );
+ }
+
+ return $this;
+ }
+
+ protected function getType()
+ {
+ if ($this->type === null) {
+ // Strip final 's' and upcase an eventual 'group'
+ $this->type = preg_replace(
+ array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/set$/'),
+ array('Group', 'Period', 'Argument', 'ApiUser', 'Set'),
+ $this->getRequest()->getControllerName()
+ );
+ }
+
+ return $this->type;
+ }
+
+ protected function getPluralType()
+ {
+ return $this->getType() . 's';
+ }
+
+ protected function getTranslatedType()
+ {
+ return $this->translate(ucfirst($this->getType()));
+ }
+
+ protected function assertTypePermission()
+ {
+ $type = strtolower($this->getPluralType());
+ // TODO: Check getPluralType usage, fix it there.
+ if ($type === 'scheduleddowntimes') {
+ $type = 'scheduled-downtimes';
+ }
+
+ return $this->assertPermission("director/$type");
+ }
+
+ protected function loadOptionalObject()
+ {
+ if ($this->params->get('uuid') || null !== $this->params->get('name') || $this->params->get('id')) {
+ $this->loadObject();
+ }
+ }
+
+ /**
+ * @return ?UuidInterface
+ * @throws InvalidPropertyException
+ * @throws NotFoundError
+ */
+ protected function getUuidFromUrl()
+ {
+ $key = null;
+ if ($uuid = $this->params->get('uuid')) {
+ $key = Uuid::fromString($uuid);
+ } elseif ($id = $this->params->get('id')) {
+ $key = (int) $id;
+ } elseif (null !== ($name = $this->params->get('name'))) {
+ $key = $name;
+ }
+ if ($key === null) {
+ $request = $this->getRequest();
+ if ($request->isApiRequest() && $request->isGet()) {
+ $this->getResponse()->setHttpResponseCode(422);
+
+ throw new InvalidPropertyException(
+ 'Cannot load object, missing parameters'
+ );
+ }
+
+ return null;
+ }
+
+ return $this->requireUuid($key);
+ }
+
+ protected function loadObject()
+ {
+ if ($this->object) {
+ throw new ProgrammingError('Loading an object twice is not very efficient');
+ }
+
+ $this->object = $this->loadSpecificObject($this->getTableName(), $this->getUuidFromUrl(), true);
+ }
+
+ protected function loadSpecificObject($tableName, $key, $showHint = false)
+ {
+ $branch = $this->getBranch();
+ $branchedObject = BranchedObject::load($this->db(), $tableName, $key, $branch);
+ $object = $branchedObject->getBranchedDbObject($this->db());
+ assert($object instanceof IcingaObject);
+ $object->setBeingLoadedFromDb();
+ if (! $this->allowsObject($object)) {
+ throw new NotFoundError('No such object available');
+ }
+ if ($showHint && $branch->isBranch() && $object->isObject() && ! $this->getRequest()->isApiRequest()) {
+ $this->content()->add(new BranchedObjectHint($branch, $this->Auth(), $branchedObject));
+ }
+
+ return $object;
+ }
+
+ protected function requireUuid($key)
+ {
+ if (! $key instanceof UuidInterface) {
+ $key = UuidLookup::findUuidForKey($key, $this->getTableName(), $this->db(), $this->getBranch());
+ if ($key === null) {
+ throw new NotFoundError('No such object available');
+ }
+ }
+
+ return $key;
+ }
+
+ protected function getTableName()
+ {
+ return DbObjectTypeRegistry::tableNameByType($this->getType());
+ }
+
+ protected function addDeploymentLink()
+ {
+ try {
+ $info = new DeploymentInfo($this->db());
+ $info->setObject($this->object);
+
+ if (! $this->getRequest()->isApiRequest()) {
+ if ($this->getBranch()->isBranch()) {
+ $this->actions()->add($this->linkToMergeBranch($this->getBranch()));
+ } else {
+ $this->actions()->add(
+ DeploymentLinkForm::create(
+ $this->db(),
+ $info,
+ $this->Auth(),
+ $this->api()
+ )->handleRequest()
+ );
+ }
+ }
+ } catch (IcingaException $e) {
+ // pass (deployment may not be set up yet)
+ }
+ }
+
+ protected function linkToMergeBranch(Branch $branch)
+ {
+ $link = Branch::requireHook()->linkToBranch($branch, $this->Auth(), $this->translate('Merge'));
+ if ($link instanceof Link) {
+ $link->addAttributes(['class' => 'icon-flapping']);
+ }
+
+ return $link;
+ }
+
+ protected function addBackToObjectLink()
+ {
+ $params = [
+ 'uuid' => $this->object->getUniqueId()->toString(),
+ ];
+
+ if ($this->object instanceof IcingaService) {
+ if (($host = $this->object->get('host')) !== null) {
+ $params['host'] = $host;
+ } elseif (($set = $this->object->get('service_set')) !== null) {
+ $params['set'] = $set;
+ }
+ }
+
+ $this->actions()->add(Link::create(
+ $this->translate('back'),
+ $this->getObjectBaseUrl(),
+ $params,
+ ['class' => 'icon-left-big']
+ ));
+
+ return $this;
+ }
+
+ protected function addObjectForm(IcingaObject $object = null)
+ {
+ $form = $this->loadObjectForm($object);
+ $this->content()->add($form);
+ $form->handleRequest();
+ return $this;
+ }
+
+ protected function loadObjectForm(IcingaObject $object = null)
+ {
+ /** @var DirectorObjectForm $class */
+ $class = sprintf(
+ 'Icinga\\Module\\Director\\Forms\\Icinga%sForm',
+ ucfirst($this->getType())
+ );
+
+ $form = $class::load()
+ ->setDb($this->db())
+ ->setAuth($this->Auth());
+
+ if ($object !== null) {
+ $form->setObject($object);
+ }
+ if (true || $form->supportsBranches()) {
+ $form->setBranch($this->getBranch());
+ }
+
+ $this->onObjectFormLoaded($form);
+
+ return $form;
+ }
+
+ protected function getObjectBaseUrl()
+ {
+ return $this->objectBaseUrl ?: 'director/' . strtolower($this->getType());
+ }
+
+ protected function hasBasketSupport()
+ {
+ return $this->object->isTemplate() || $this->object->isGroup();
+ }
+
+ protected function onObjectFormLoaded(DirectorObjectForm $form)
+ {
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws NotFoundError
+ */
+ protected function requireObject()
+ {
+ if (! $this->object) {
+ $this->getResponse()->setHttpResponseCode(404);
+ if (null === $this->params->get('name')) {
+ throw new NotFoundError('You need to pass a "name" parameter to access a specific object');
+ } else {
+ throw new NotFoundError('No such object available');
+ }
+ }
+
+ return $this->object;
+ }
+
+ protected function showOptionalBranchActivity($activityTable)
+ {
+ $branch = $this->getBranch();
+ if ($branch->isBranch() && (int) $this->params->get('page', '1') === 1) {
+ $table = new BranchActivityTable($branch->getUuid(), $this->db(), $this->object->getUniqueId());
+ if (count($table) > 0) {
+ $this->content()->add(Hint::info(Html::sprintf($this->translate(
+ 'The following modifications are visible in this %s only...'
+ ), Branch::requireHook()->linkToBranch(
+ $branch,
+ $this->Auth(),
+ $this->translate('configuration branch')
+ ))));
+ $this->content()->add($table);
+ if (count($activityTable) === 0) {
+ return;
+ }
+ $this->content()->add(Html::tag('br'));
+ $this->content()->add(Hint::ok($this->translate(
+ '...and the modifications below are already in the main branch:'
+ )));
+ $this->content()->add(Html::tag('br'));
+ }
+ }
+ }
+}
diff --git a/library/Director/Web/Controller/ObjectsController.php b/library/Director/Web/Controller/ObjectsController.php
new file mode 100644
index 0000000..8c10b44
--- /dev/null
+++ b/library/Director/Web/Controller/ObjectsController.php
@@ -0,0 +1,548 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\NotFoundError;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Forms\IcingaMultiEditForm;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\RestApi\IcingaObjectsHandler;
+use Icinga\Module\Director\Web\ActionBar\ObjectsActionBar;
+use Icinga\Module\Director\Web\ActionBar\TemplateActionBar;
+use Icinga\Module\Director\Web\Form\FormLoader;
+use Icinga\Module\Director\Web\Table\ApplyRulesTable;
+use Icinga\Module\Director\Web\Table\ObjectSetTable;
+use Icinga\Module\Director\Web\Table\ObjectsTable;
+use Icinga\Module\Director\Web\Table\TemplatesTable;
+use Icinga\Module\Director\Web\Tabs\ObjectsTabs;
+use Icinga\Module\Director\Web\Tree\TemplateTreeRenderer;
+use gipfl\IcingaWeb2\Link;
+use Icinga\Module\Director\Web\Widget\AdditionalTableActions;
+use Icinga\Module\Director\Web\Widget\BranchedObjectsHint;
+use InvalidArgumentException;
+use Ramsey\Uuid\Uuid;
+
+abstract class ObjectsController extends ActionController
+{
+ use BranchHelper;
+
+ protected $isApified = true;
+
+ /** @var ObjectsTable */
+ protected $table;
+
+ protected function checkDirectorPermissions()
+ {
+ if ($this->getRequest()->getActionName() !== 'sets') {
+ $this->assertPermission('director/' . $this->getPluralBaseType());
+ }
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ */
+ protected function addObjectsTabs()
+ {
+ $tabName = $this->getRequest()->getActionName();
+ if (substr($this->getType(), -5) === 'Group') {
+ $tabName = 'groups';
+ }
+ $this->tabs(new ObjectsTabs(
+ $this->getBaseType(),
+ $this->Auth(),
+ $this->getBaseObjectUrl()
+ ))->activate($tabName);
+
+ return $this;
+ }
+
+ /**
+ * @return IcingaObjectsHandler
+ * @throws NotFoundError
+ */
+ protected function apiRequestHandler()
+ {
+ $request = $this->getRequest();
+ $table = $this->getTable();
+ if ($request->getControllerName() === 'services'
+ && $host = $this->params->get('host')
+ ) {
+ $host = IcingaHost::load($host, $this->db());
+ $table->getQuery()->where('o.host_id = ?', $host->get('id'));
+ }
+
+ if ($request->getActionName() === 'templates') {
+ $table->filterObjectType('template');
+ } elseif ($request->getActionName() === 'applyrules') {
+ $table->filterObjectType('apply');
+ }
+ $search = $this->params->get('q');
+ if ($search !== null && \strlen($search) > 0) {
+ $table->search($search);
+ }
+
+ return (new IcingaObjectsHandler(
+ $request,
+ $this->getResponse(),
+ $this->db()
+ ))->setTable($table);
+ }
+
+ /**
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws NotFoundError
+ */
+ public function indexAction()
+ {
+ if ($this->getRequest()->isApiRequest()) {
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $type = $this->getType();
+ if ($this->params->get('format') === 'json') {
+ $filename = sprintf(
+ "director-${type}_%s.json",
+ date('YmdHis')
+ );
+ $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true);
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $this
+ ->addObjectsTabs()
+ ->setAutorefreshInterval(10)
+ ->addTitle($this->translate(ucfirst($this->getPluralType())))
+ ->actions(new ObjectsActionBar($this->getBaseObjectUrl(), $this->url()));
+
+ $this->content()->add(new BranchedObjectsHint($this->getBranch(), $this->Auth()));
+
+ if ($type === 'command' && $this->params->get('type') === 'external_object') {
+ $this->tabs()->activate('external');
+ }
+
+ // Hint: might be used in controllers extending this
+ $this->table = $this->eventuallyFilterCommand($this->getTable());
+
+ $this->table->renderTo($this);
+ (new AdditionalTableActions($this->getAuth(), $this->url(), $this->table))
+ ->appendTo($this->actions());
+ }
+
+ /**
+ * @return ObjectsTable
+ */
+ protected function getTable()
+ {
+ $table = ObjectsTable::create($this->getType(), $this->db())
+ ->setAuth($this->getAuth())
+ ->setBranchUuid($this->getBranchUuid())
+ ->setBaseObjectUrl($this->getBaseObjectUrl());
+
+ return $table;
+ }
+
+ /**
+ * @return ApplyRulesTable
+ * @throws NotFoundError
+ */
+ protected function getApplyRulesTable()
+ {
+ $table = new ApplyRulesTable($this->db());
+ $table->setType($this->getType())
+ ->setBaseObjectUrl($this->getBaseObjectUrl());
+ $this->eventuallyFilterCommand($table);
+
+ return $table;
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function edittemplatesAction()
+ {
+ $this->commonForEdit();
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function editAction()
+ {
+ $this->commonForEdit();
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function commonForEdit()
+ {
+ $type = ucfirst($this->getType());
+
+ if (empty($this->multiEdit)) {
+ throw new NotFoundError('Cannot edit multiple "%s" instances', $type);
+ }
+
+ $objects = $this->loadMultiObjectsFromParams();
+ if (empty($objects)) {
+ throw new NotFoundError('No "%s" instances have been loaded', $type);
+ }
+ $formName = 'icinga' . $type;
+ $form = IcingaMultiEditForm::load()
+ ->setBranch($this->getBranch())
+ ->setObjects($objects)
+ ->pickElementsFrom($this->loadForm($formName), $this->multiEdit);
+ if ($type === 'Service') {
+ $form->setListUrl('director/services');
+ } elseif ($type === 'Host') {
+ $form->setListUrl('director/hosts');
+ }
+
+ $form->handleRequest();
+
+ $this
+ ->addSingleTab($this->translate('Multiple objects'))
+ ->addTitle(
+ $this->translate('Modify %d objects'),
+ count($objects)
+ )->content()->add($form);
+ }
+
+ /**
+ * Loads the TemplatesTable or the TemplateTreeRenderer
+ *
+ * Passing render=tree switches to the tree view.
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Security\SecurityException
+ * @throws NotFoundError
+ */
+ public function templatesAction()
+ {
+ if ($this->getRequest()->isApiRequest()) {
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+ $type = $this->getType();
+
+ if ($this->params->get('format') === 'json') {
+ $filename = sprintf(
+ "director-${type}-templates_%s.json",
+ date('YmdHis')
+ );
+ $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true);
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $shortType = IcingaObject::createByType($type)->getShortTableName();
+ $this
+ ->assertPermission('director/admin')
+ ->addObjectsTabs()
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('All your %s Templates'),
+ $this->translate(ucfirst($type))
+ )
+ ->actions(new TemplateActionBar($shortType, $this->url()));
+
+ if ($this->params->get('render') === 'tree') {
+ TemplateTreeRenderer::showType($shortType, $this, $this->db());
+ } else {
+ $table = TemplatesTable::create($shortType, $this->db());
+ $this->eventuallyFilterCommand($table);
+ $table->renderTo($this);
+ (new AdditionalTableActions($this->getAuth(), $this->url(), $table))
+ ->appendTo($this->actions());
+ }
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Security\SecurityException
+ */
+ protected function assertApplyRulePermission()
+ {
+ return $this->assertPermission('director/admin');
+ }
+
+ /**
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Security\SecurityException
+ * @throws NotFoundError
+ */
+ public function applyrulesAction()
+ {
+ if ($this->getRequest()->isApiRequest()) {
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $type = $this->getType();
+
+ if ($this->params->get('format') === 'json') {
+ $filename = sprintf(
+ "director-${type}-applyrules_%s.json",
+ date('YmdHis')
+ );
+ $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true);
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $tType = $this->translate(ucfirst($type));
+ $this
+ ->assertApplyRulePermission()
+ ->addObjectsTabs()
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('All your %s Apply Rules'),
+ $tType
+ );
+ $baseUrl = 'director/' . $this->getBaseObjectUrl();
+ $this->actions()
+ //->add($this->getBackToDashboardLink())
+ ->add(
+ Link::create(
+ $this->translate('Add'),
+ "${baseUrl}/add",
+ ['type' => 'apply'],
+ [
+ 'title' => sprintf(
+ $this->translate('Create a new %s Apply Rule'),
+ $tType
+ ),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+
+ $table = $this->getApplyRulesTable();
+ $table->renderTo($this);
+ (new AdditionalTableActions($this->getAuth(), $this->url(), $table))
+ ->appendTo($this->actions());
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function setsAction()
+ {
+ $type = $this->getType();
+ $tType = $this->translate(ucfirst($type));
+ $this
+ ->assertPermission('director/' . $this->getBaseType() . 'sets')
+ ->addObjectsTabs()
+ ->requireSupportFor('Sets')
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('Icinga %s Sets'),
+ $tType
+ );
+
+ $this->actions()->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/${type}set/add",
+ null,
+ [
+ 'title' => sprintf(
+ $this->translate('Create a new %s Set'),
+ $tType
+ ),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+
+ ObjectSetTable::create($type, $this->db(), $this->getAuth())
+ ->setBranch($this->getBranch())
+ ->renderTo($this);
+ }
+
+ /**
+ * @return array
+ * @throws NotFoundError
+ */
+ protected function loadMultiObjectsFromParams()
+ {
+ $filter = Filter::fromQueryString($this->params->toString());
+ $type = $this->getType();
+ $objects = array();
+ $db = $this->db();
+ $class = DbObjectTypeRegistry::classByType($type);
+ $table = DbObjectTypeRegistry::tableNameByType($type);
+ $store = new DbObjectStore($db, $this->getBranch());
+
+ /** @var $filter FilterChain */
+ foreach ($filter->filters() as $sub) {
+ /** @var $sub FilterChain */
+ foreach ($sub->filters() as $ex) {
+ /** @var $ex FilterChain|FilterExpression */
+ $col = $ex->getColumn();
+ if ($ex->isExpression()) {
+ if ($col === 'name') {
+ $name = $ex->getExpression();
+ if ($type === 'service') {
+ $key = [
+ 'object_type' => 'template',
+ 'object_name' => $name
+ ];
+ } else {
+ $key = $name;
+ }
+ $objects[$name] = $class::load($key, $db);
+ } elseif ($col === 'id') {
+ $name = $ex->getExpression();
+ $objects[$name] = $class::load($name, $db);
+ } elseif ($col === 'uuid') {
+ $object = $store->load($table, Uuid::fromString($ex->getExpression()));
+ $objects[$object->getObjectName()] = $object;
+ } else {
+ throw new InvalidArgumentException("'$col' is no a valid key component for '$type'");
+ }
+ }
+ }
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return \Icinga\Module\Director\Web\Form\QuickForm
+ */
+ public function loadForm($name)
+ {
+ $form = FormLoader::load($name, $this->Module());
+ if ($this->getRequest()->isApiRequest()) {
+ // TODO: Ask form for API support?
+ $form->setApiRequest();
+ }
+
+ return $form;
+ }
+
+ /**
+ * @param ZfQueryBasedTable $table
+ * @return ZfQueryBasedTable
+ * @throws NotFoundError
+ */
+ protected function eventuallyFilterCommand(ZfQueryBasedTable $table)
+ {
+ if ($this->params->get('command')) {
+ $command = IcingaCommand::load($this->params->get('command'), $this->db());
+ switch ($this->getBaseType()) {
+ case 'host':
+ case 'service':
+ $table->getQuery()->where(
+ $this->db()->getDbAdapter()->quoteInto(
+ '(o.check_command_id = ? OR o.event_command_id = ?)',
+ $command->getAutoincId()
+ )
+ );
+ break;
+ case 'notification':
+ $table->getQuery()->where(
+ 'o.command_id = ?',
+ $command->getAutoincId()
+ );
+ break;
+ }
+ }
+
+ return $table;
+ }
+
+ /**
+ * @param $feature
+ * @return $this
+ * @throws NotFoundError
+ */
+ protected function requireSupportFor($feature)
+ {
+ if ($this->supports($feature) !== true) {
+ throw new NotFoundError(
+ '%s does not support %s',
+ $this->getType(),
+ $feature
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param $feature
+ * @return bool
+ */
+ protected function supports($feature)
+ {
+ $func = "supports$feature";
+ return IcingaObject::createByType($this->getType())->$func();
+ }
+
+ /**
+ * @return string
+ */
+ protected function getBaseType()
+ {
+ $type = $this->getType();
+ if (substr($type, -5) === 'Group') {
+ return substr($type, 0, -5);
+ } else {
+ return $type;
+ }
+ }
+
+ protected function getBaseObjectUrl()
+ {
+ return $this->getType();
+ }
+
+ /**
+ * @return string
+ */
+ protected function getType()
+ {
+ // Strip final 's' and upcase an eventual 'group'
+ return preg_replace(
+ array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/dependencie$/', '/set$/'),
+ array('Group', 'Period', 'Argument', 'ApiUser', 'dependency', 'Set'),
+ str_replace(
+ 'template',
+ '',
+ substr($this->getRequest()->getControllerName(), 0, -1)
+ )
+ );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getPluralType()
+ {
+ return preg_replace('/cys$/', 'cies', $this->getType() . 's');
+ }
+
+ /**
+ * @return string
+ */
+ protected function getPluralBaseType()
+ {
+ return preg_replace('/cys$/', 'cies', $this->getBaseType() . 's');
+ }
+}
diff --git a/library/Director/Web/Controller/TemplateController.php b/library/Director/Web/Controller/TemplateController.php
new file mode 100644
index 0000000..c368a82
--- /dev/null
+++ b/library/Director/Web/Controller/TemplateController.php
@@ -0,0 +1,243 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Controller\Extension\DirectorDb;
+use Icinga\Module\Director\Web\Table\ApplyRulesTable;
+use Icinga\Module\Director\Web\Table\ObjectsTable;
+use Icinga\Module\Director\Web\Table\TemplatesTable;
+use Icinga\Module\Director\Web\Table\TemplateUsageTable;
+use Icinga\Module\Director\Web\Tabs\ObjectTabs;
+use Icinga\Module\Director\Web\Widget\UnorderedList;
+use ipl\Html\FormattedString;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\CompatController;
+
+abstract class TemplateController extends CompatController
+{
+ use DirectorDb;
+
+ /** @var IcingaObject */
+ protected $template;
+
+ public function objectsAction()
+ {
+ $template = $this->requireTemplate();
+ $plural = $this->getTranslatedPluralType();
+ $this
+ ->addSingleTab($plural)
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('%s based on %s'),
+ $plural,
+ $template->getObjectName()
+ )->addBackToUsageLink($template);
+
+ ObjectsTable::create($this->getType(), $this->db())
+ ->setAuth($this->Auth())
+ ->setBaseObjectUrl($this->getBaseObjectUrl())
+ ->filterTemplate($template, $this->getInheritance())
+ ->renderTo($this);
+ }
+
+ public function applyrulesAction()
+ {
+ $type = $this->getType();
+ $template = $this->requireTemplate();
+ $this
+ ->addSingleTab(sprintf($this->translate('Applied %s'), $this->getTranslatedPluralType()))
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('Notification Apply Rules based on %s'),
+ $template->getObjectName()
+ )->addBackToUsageLink($template);
+
+ ApplyRulesTable::create($type, $this->db())
+ ->setBaseObjectUrl($this->getBaseObjectUrl())
+ ->filterTemplate($template, $this->params->get('inheritance', 'direct'))
+ ->renderTo($this);
+ }
+
+ public function templatesAction()
+ {
+ $template = $this->requireTemplate();
+ $typeName = $this->getTranslatedType();
+ $this
+ ->addSingleTab(sprintf($this->translate('%s Templates'), $typeName))
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('%s templates based on %s'),
+ $typeName,
+ $template->getObjectName()
+ )->addBackToUsageLink($template);
+
+ TemplatesTable::create($this->getType(), $this->db())
+ ->filterTemplate($template, $this->getInheritance())
+ ->renderTo($this);
+ }
+
+ protected function getInheritance()
+ {
+ return $this->params->get('inheritance', 'direct');
+ }
+
+ protected function addBackToUsageLink(IcingaObject $template)
+ {
+ $type = $this->getType();
+ $this->actions()->add(
+ Link::create(
+ $this->translate('Back'),
+ "director/${type}template/usage",
+ ['name' => $template->getObjectName()],
+ ['class' => 'icon-left-big']
+ )
+ );
+
+ return $this;
+ }
+
+ public function usageAction()
+ {
+ $template = $this->requireTemplate();
+ if (! $template->isTemplate() && $template instanceof IcingaCommand) {
+ $this->redirectNow($this->url()->setPath('director/command'));
+ }
+ $templateName = $template->getObjectName();
+
+ $type = $this->getType();
+ $this->tabs(new ObjectTabs($type, $this->Auth(), $template))->activate('modify');
+ $this
+ ->addTitle($this->translate('Template: %s'), $templateName)
+ ->setAutorefreshInterval(10);
+
+ $this->actions()->add([
+ Link::create(
+ $this->translate('Modify'),
+ "director/$type/edit",
+ ['uuid' => $template->getUniqueId()->toString()],
+ ['class' => 'icon-edit']
+ )
+ ]);
+ if ($template instanceof ExportInterface) {
+ $this->actions()->add(Link::create(
+ $this->translate('Add to Basket'),
+ 'director/basket/add',
+ [
+ 'type' => ucfirst($this->getType()) . 'Template',
+ 'names' => $template->getUniqueIdentifier()
+ ],
+ ['class' => 'icon-tag']
+ ));
+ }
+
+ $list = new UnorderedList([], [
+ 'class' => 'vertical-action-list'
+ ]);
+
+ $auth = $this->Auth();
+
+ if ($type !== 'notification') {
+ $list->addItem(new FormattedString(
+ $this->translate('Create a new %s inheriting from this template'),
+ [Link::create(
+ $this->translate('Object'),
+ "director/$type/add",
+ ['imports' => $templateName, 'type' => 'object']
+ )]
+ ));
+ }
+ if ($auth->hasPermission('director/admin')) {
+ $list->addItem(new FormattedString(
+ $this->translate('Create a new %s inheriting from this one'),
+ [Link::create(
+ $this->translate('Template'),
+ "director/$type/add",
+ ['imports' => $templateName, 'type' => 'template']
+ )]
+ ));
+ }
+ if ($template->supportsApplyRules()) {
+ $list->addItem(new FormattedString(
+ $this->translate('Create a new %s inheriting from this template'),
+ [Link::create(
+ $this->translate('Apply Rule'),
+ "director/$type/add",
+ ['imports' => $templateName, 'type' => 'apply']
+ )]
+ ));
+ }
+
+ $typeName = $this->getTranslatedType();
+ $this->content()->add(Html::sprintf(
+ $this->translate(
+ 'This is the "%s" %s Template. Based on this, you might want to:'
+ ),
+ $typeName,
+ $templateName
+ ))->add(
+ $list
+ )->add(
+ Html::tag('h2', null, $this->translate('Current Template Usage'))
+ );
+
+ try {
+ $this->content()->add(
+ TemplateUsageTable::forTemplate($template)
+ );
+ } catch (NestingError $e) {
+ $this->content()->add(Hint::error($e->getMessage()));
+ }
+ }
+
+ protected function getType()
+ {
+ return $this->template()->getShortTableName();
+ }
+
+ protected function getPluralType()
+ {
+ return preg_replace(
+ '/cys$/',
+ 'cies',
+ $this->template()->getShortTableName() . 's'
+ );
+ }
+
+ protected function getTranslatedType()
+ {
+ return $this->translate(ucfirst($this->getType()));
+ }
+
+ protected function getTranslatedPluralType()
+ {
+ return $this->translate(ucfirst($this->getPluralType()));
+ }
+
+ protected function getBaseObjectUrl()
+ {
+ return $this->getType();
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ protected function template()
+ {
+ if ($this->template === null) {
+ $this->template = $this->requireTemplate();
+ }
+
+ return $this->template;
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ abstract protected function requireTemplate();
+}
diff --git a/library/Director/Web/Form/ClickHereForm.php b/library/Director/Web/Form/ClickHereForm.php
new file mode 100644
index 0000000..abba9d7
--- /dev/null
+++ b/library/Director/Web/Form/ClickHereForm.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\InlineForm;
+
+class ClickHereForm extends InlineForm
+{
+ use TranslationHelper;
+
+ protected $hasBeenClicked = false;
+
+ protected function assemble()
+ {
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('here'),
+ 'class' => 'link-button'
+ ]);
+ }
+
+ public function hasBeenClicked()
+ {
+ return $this->hasBeenClicked;
+ }
+
+ public function onSuccess()
+ {
+ $this->hasBeenClicked = true;
+ }
+}
diff --git a/library/Director/Web/Form/CloneImportSourceForm.php b/library/Director/Web/Form/CloneImportSourceForm.php
new file mode 100644
index 0000000..0849dd4
--- /dev/null
+++ b/library/Director/Web/Form/CloneImportSourceForm.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Module\Director\Data\Exporter;
+use ipl\Html\Form;
+use ipl\Html\FormDecorator\DdDtDecorator;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\ImportSource;
+
+class CloneImportSourceForm extends Form
+{
+ use TranslationHelper;
+
+ /** @var ImportSource */
+ protected $source;
+
+ /** @var ImportSource|null */
+ protected $newSource;
+
+ public function __construct(ImportSource $source)
+ {
+ $this->setDefaultElementDecorator(new DdDtDecorator());
+ $this->source = $source;
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('text', 'source_name', [
+ 'label' => $this->translate('New name'),
+ 'value' => $this->source->get('source_name'),
+ ]);
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('Clone')
+ ]);
+ }
+
+ /**
+ * @return \Icinga\Module\Director\Db
+ */
+ protected function getTargetDb()
+ {
+ return $this->source->getConnection();
+ }
+
+ /**
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function onSuccess()
+ {
+ $db = $this->getTargetDb();
+ $export = (new Exporter($db))->export($this->source);
+ $newName = $this->getElement('source_name')->getValue();
+ $export->source_name = $newName;
+ unset($export->originalId);
+ if (ImportSource::existsWithName($newName, $db)) {
+ $this->getElement('source_name')->addMessage('Name already exists');
+ }
+ $this->newSource = ImportSource::import($export, $db);
+ $this->newSource->store();
+ }
+
+ public function getSuccessUrl()
+ {
+ if ($this->newSource === null) {
+ return parent::getSuccessUrl();
+ } else {
+ return Url::fromPath('director/importsource', ['id' => $this->newSource->get('id')]);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/CloneSyncRuleForm.php b/library/Director/Web/Form/CloneSyncRuleForm.php
new file mode 100644
index 0000000..f90b593
--- /dev/null
+++ b/library/Director/Web/Form/CloneSyncRuleForm.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Module\Director\Data\Exporter;
+use ipl\Html\Form;
+use ipl\Html\FormDecorator\DdDtDecorator;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\SyncRule;
+
+class CloneSyncRuleForm extends Form
+{
+ use TranslationHelper;
+
+ /** @var SyncRule */
+ protected $rule;
+
+ /** @var SyncRule|null */
+ protected $newRule;
+
+ public function __construct(SyncRule $rule)
+ {
+ $this->setDefaultElementDecorator(new DdDtDecorator());
+ $this->rule = $rule;
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('text', 'rule_name', [
+ 'label' => $this->translate('New name'),
+ 'value' => $this->rule->get('rule_name'),
+ ]);
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('Clone')
+ ]);
+ }
+
+ /**
+ * @return \Icinga\Module\Director\Db
+ */
+ protected function getTargetDb()
+ {
+ return $this->rule->getConnection();
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function onSuccess()
+ {
+ $db = $this->getTargetDb();
+ $exporter = new Exporter($db);
+
+ $export = $exporter->export($this->rule);
+ $newName = $this->getValue('rule_name');
+ $export->rule_name = $newName;
+ unset($export->originalId);
+
+ if (SyncRule::existsWithName($newName, $db)) {
+ $this->getElement('rule_name')->addMessage('Name already exists');
+ }
+ $this->newRule = SyncRule::import($export, $db);
+ $this->newRule->store();
+ }
+
+ public function getSuccessUrl()
+ {
+ if ($this->newRule === null) {
+ return parent::getSuccessUrl();
+ } else {
+ return Url::fromPath('director/syncrule', ['id' => $this->newRule->get('id')]);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/CsrfToken.php b/library/Director/Web/Form/CsrfToken.php
new file mode 100644
index 0000000..24edf88
--- /dev/null
+++ b/library/Director/Web/Form/CsrfToken.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+class CsrfToken
+{
+ /**
+ * Check whether the given token is valid
+ *
+ * @param string $token Token
+ *
+ * @return bool
+ */
+ public static function isValid($token)
+ {
+ if (strpos($token, '|') === false) {
+ return false;
+ }
+
+ list($seed, $token) = explode('|', $elementValue);
+
+ if (!is_numeric($seed)) {
+ return false;
+ }
+
+ return $token === hash('sha256', self::getSessionId() . $seed);
+ }
+
+ /**
+ * Create a new token
+ *
+ * @return string
+ */
+ public static function generate()
+ {
+ $seed = mt_rand();
+ $token = hash('sha256', self::getSessionId() . $seed);
+
+ return sprintf('%s|%s', $seed, $token);
+ }
+
+ /**
+ * Get current session id
+ *
+ * TODO: we should do this through our App or Session object
+ *
+ * @return string
+ */
+ protected static function getSessionId()
+ {
+ return session_id();
+ }
+}
diff --git a/library/Director/Web/Form/DbSelectorForm.php b/library/Director/Web/Form/DbSelectorForm.php
new file mode 100644
index 0000000..52fe5ea
--- /dev/null
+++ b/library/Director/Web/Form/DbSelectorForm.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Web\Response;
+use ipl\Html\Form;
+use Icinga\Web\Window;
+
+class DbSelectorForm extends Form
+{
+ protected $defaultAttributes = [
+ 'class' => 'db-selector'
+ ];
+
+ protected $allowedNames;
+
+ /** @var Window */
+ protected $window;
+
+ protected $response;
+
+ public function __construct(Response $response, Window $window, $allowedNames)
+ {
+ $this->response = $response;
+ $this->window = $window;
+ $this->allowedNames = $allowedNames;
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('hidden', 'DbSelector', [
+ 'value' => 'sent'
+ ]);
+ $this->addElement('select', 'db_resource', [
+ 'options' => $this->allowedNames,
+ 'class' => 'autosubmit',
+ 'value' => $this->getSession()->get('db_resource')
+ ]);
+ }
+
+ /**
+ * A base class should handle this, based on hidden fields
+ *
+ * @return bool
+ */
+ public function hasBeenSubmitted()
+ {
+ return $this->hasBeenSent() && $this->getRequestParam('DbSelector') === 'sent';
+ }
+
+ public function onSuccess()
+ {
+ $this->getSession()->set('db_resource', $this->getElement('db_resource')->getValue());
+ $this->response->redirectAndExit(Url::fromRequest($this->getRequest()));
+ }
+
+ protected function getRequestParam($name, $default = null)
+ {
+ $request = $this->getRequest();
+ if ($request === null) {
+ return $default;
+ }
+ if ($request->getMethod() === 'POST') {
+ $params = $request->getParsedBody();
+ } elseif ($this->getMethod() === 'GET') {
+ parse_str($request->getUri()->getQuery(), $params);
+ } else {
+ $params = [];
+ }
+
+ if (array_key_exists($name, $params)) {
+ return $params[$name];
+ }
+
+ return $default;
+ }
+ /**
+ * @return \Icinga\Web\Session\SessionNamespace
+ */
+ protected function getSession()
+ {
+ return $this->window->getSessionNamespace('director');
+ }
+}
diff --git a/library/Director/Web/Form/Decorator/ViewHelperRaw.php b/library/Director/Web/Form/Decorator/ViewHelperRaw.php
new file mode 100644
index 0000000..a3aefbf
--- /dev/null
+++ b/library/Director/Web/Form/Decorator/ViewHelperRaw.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Decorator;
+
+use Zend_Form_Decorator_ViewHelper as ViewHelper;
+use Zend_Form_Element as Element;
+
+class ViewHelperRaw extends ViewHelper
+{
+ public function getValue($element)
+ {
+ return $element->getUnfilteredValue();
+ }
+}
diff --git a/library/Director/Web/Form/DirectorForm.php b/library/Director/Web/Form/DirectorForm.php
new file mode 100644
index 0000000..145be5b
--- /dev/null
+++ b/library/Director/Web/Form/DirectorForm.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Director\Db;
+
+abstract class DirectorForm extends QuickForm
+{
+ /** @var Db */
+ protected $db;
+
+ /**
+ * @param Db $db
+ * @return $this
+ */
+ public function setDb(Db $db)
+ {
+ $this->db = $db;
+ return $this;
+ }
+
+ /**
+ * @return Db
+ */
+ public function getDb()
+ {
+ return $this->db;
+ }
+
+ /**
+ * @return static
+ */
+ public static function load()
+ {
+ return new static([
+ 'icingaModule' => Icinga::App()->getModuleManager()->getModule('director')
+ ]);
+ }
+
+ public function addBoolean($key, $options, $default = null)
+ {
+ if ($default === null) {
+ return $this->addElement('OptionalYesNo', $key, $options);
+ } else {
+ $this->addElement('YesNo', $key, $options);
+ return $this->getElement($key)->setValue($default);
+ }
+ }
+
+ protected function optionalBoolean($key, $label, $description)
+ {
+ return $this->addBoolean($key, array(
+ 'label' => $label,
+ 'description' => $description
+ ));
+ }
+}
diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php
new file mode 100644
index 0000000..b70bd7b
--- /dev/null
+++ b/library/Director/Web/Form/DirectorObjectForm.php
@@ -0,0 +1,1734 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Exception;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Hook\IcingaObjectFormHook;
+use Icinga\Module\Director\IcingaConfig\StateFilterSet;
+use Icinga\Module\Director\IcingaConfig\TypeFilterSet;
+use Icinga\Module\Director\Objects\IcingaTemplateChoice;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Director\Web\Form\Element\ExtensibleSet;
+use Icinga\Module\Director\Web\Form\Validate\NamePattern;
+use Zend_Form_Element as ZfElement;
+use Zend_Form_Element_Select as ZfSelect;
+
+abstract class DirectorObjectForm extends DirectorForm
+{
+ const GROUP_ORDER_OBJECT_DEFINITION = 20;
+ const GROUP_ORDER_RELATED_OBJECTS = 25;
+ const GROUP_ORDER_ASSIGN = 30;
+ const GROUP_ORDER_CHECK_EXECUTION = 40;
+ const GROUP_ORDER_CUSTOM_FIELDS = 50;
+ const GROUP_ORDER_CUSTOM_FIELD_CATEGORIES = 60;
+ const GROUP_ORDER_EVENT_FILTERS = 700;
+ const GROUP_ORDER_EXTRA_INFO = 750;
+ const GROUP_ORDER_CLUSTERING = 800;
+ const GROUP_ORDER_BUTTONS = 1000;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var Branch */
+ protected $branch;
+
+ protected $objectName;
+
+ protected $className;
+
+ protected $deleteButtonName;
+
+ protected $displayGroups = [];
+
+ protected $resolvedImports;
+
+ protected $listUrl;
+
+ /** @var Auth */
+ private $auth;
+
+ private $choiceElements = [];
+
+ protected $preferredObjectType;
+
+ /** @var IcingaObjectFieldLoader */
+ protected $fieldLoader;
+
+ private $allowsExperimental;
+
+ private $presetImports;
+
+ private $earlyProperties = array(
+ // 'imports',
+ 'check_command',
+ 'check_command_id',
+ 'has_agent',
+ 'command',
+ 'command_id',
+ 'event_command',
+ 'event_command_id',
+ );
+
+ public function setPreferredObjectType($type)
+ {
+ $this->preferredObjectType = $type;
+ return $this;
+ }
+
+ public function setAuth(Auth $auth)
+ {
+ $this->auth = $auth;
+ return $this;
+ }
+
+ public function getAuth()
+ {
+ if ($this->auth === null) {
+ $this->auth = Auth::getInstance();
+ }
+ return $this->auth;
+ }
+
+ protected function eventuallyAddNameRestriction($restrictionName)
+ {
+ $restrictions = $this->getAuth()->getRestrictions($restrictionName);
+ if (! empty($restrictions)) {
+ $this->getElement('object_name')->addValidator(
+ new NamePattern($restrictions)
+ );
+ }
+
+ return $this;
+ }
+
+ public function presetImports($imports)
+ {
+ if (! empty($imports)) {
+ if (is_array($imports)) {
+ $this->presetImports = $imports;
+ } else {
+ $this->presetImports = array($imports);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return DbObject|DbObjectWithSettings|IcingaObject
+ */
+ protected function object()
+ {
+ if ($this->object === null) {
+ $values = $this->getValues();
+ /** @var DbObject|IcingaObject $class */
+ $class = $this->getObjectClassname();
+ if ($this->preferredObjectType) {
+ $values['object_type'] = $this->preferredObjectType;
+ }
+ if ($this->presetImports) {
+ $values['imports'] = $this->presetImports;
+ }
+
+ $this->object = $class::create($values, $this->db);
+ } else {
+ if (! $this->object->hasConnection()) {
+ $this->object->setConnection($this->db);
+ }
+ }
+
+ return $this->object;
+ }
+
+ protected function extractChoicesFromPost($post)
+ {
+ $imports = [];
+
+ foreach ($this->choiceElements as $other) {
+ $name = $other->getName();
+ if (array_key_exists($name, $post)) {
+ $value = $post[$name];
+ if (is_string($value)) {
+ $imports[] = $value;
+ } elseif (is_array($value)) {
+ foreach ($value as $chosen) {
+ $imports[] = $chosen;
+ }
+ }
+ }
+ }
+
+ return $imports;
+ }
+
+ protected function assertResolvedImports()
+ {
+ if ($this->resolvedImports !== null) {
+ return $this->resolvedImports;
+ }
+
+ $object = $this->object;
+
+ if (! $object instanceof IcingaObject) {
+ return $this->setResolvedImports(false);
+ }
+ if (! $object->supportsImports()) {
+ return $this->setResolvedImports(false);
+ }
+
+ if ($this->hasBeenSent()) {
+ // prefill special properties, required to resolve fields and similar
+ $post = $this->getRequest()->getPost();
+
+ $key = 'imports';
+ if ($el = $this->getElement($key)) {
+ if (array_key_exists($key, $post)) {
+ $imports = $post[$key];
+ if (! is_array($imports)) {
+ $imports = array($imports);
+ }
+ $imports = array_filter(array_values(array_merge(
+ $imports,
+ $this->extractChoicesFromPost($post)
+ )), 'strlen');
+
+ /** @var ZfElement $el */
+ $this->populate([$key => $imports]);
+ $el->setValue($imports);
+ if (! $this->tryToSetObjectPropertyFromElement($object, $el, $key)) {
+ return $this->resolvedImports = false;
+ }
+ }
+ } elseif ($this->presetImports) {
+ $imports = array_values(array_merge(
+ $this->presetImports,
+ $this->extractChoicesFromPost($post)
+ ));
+ if (! $this->eventuallySetImports($imports)) {
+ return $this->resolvedImports = false;
+ }
+ } else {
+ if (! empty($this->choiceElements)) {
+ if (! $this->eventuallySetImports($this->extractChoicesFromPost($post))) {
+ return $this->resolvedImports = false;
+ }
+ }
+ }
+
+ foreach ($this->earlyProperties as $key) {
+ if ($el = $this->getElement($key)) {
+ if (array_key_exists($key, $post)) {
+ $this->populate([$key => $post[$key]]);
+ $this->tryToSetObjectPropertyFromElement($object, $el, $key);
+ }
+ }
+ }
+ }
+
+ try {
+ $object->listAncestorIds();
+ } catch (NestingError $e) {
+ $this->addUniqueErrorMessage($e->getMessage());
+ return $this->resolvedImports = false;
+ } catch (Exception $e) {
+ $this->addException($e, 'imports');
+ return $this->resolvedImports = false;
+ }
+
+ return $this->setResolvedImports();
+ }
+
+ protected function eventuallySetImports($imports)
+ {
+ try {
+ $this->object()->set('imports', $imports);
+ return true;
+ } catch (Exception $e) {
+ $this->addException($e, 'imports');
+ return false;
+ }
+ }
+
+ protected function tryToSetObjectPropertyFromElement(
+ IcingaObject $object,
+ ZfElement $element,
+ $key
+ ) {
+ $old = null;
+ try {
+ $old = $object->get($key);
+ $object->set($key, $element->getValue());
+ $object->resolveUnresolvedRelatedProperties();
+
+ if ($key === 'imports') {
+ $object->imports()->getObjects();
+ }
+ return true;
+ } catch (Exception $e) {
+ if ($old !== null) {
+ $object->set($key, $old);
+ }
+ $this->addException($e, $key);
+ return false;
+ }
+ }
+
+ public function setResolvedImports($resolved = true)
+ {
+ return $this->resolvedImports = $resolved;
+ }
+
+ public function isObject()
+ {
+ return $this->getSentOrObjectValue('object_type') === 'object';
+ }
+
+ public function isTemplate()
+ {
+ return $this->getSentOrObjectValue('object_type') === 'template';
+ }
+
+ // TODO: move to a subform
+ protected function handleRanges(IcingaObject $object, &$values)
+ {
+ if (! $object->supportsRanges()) {
+ return;
+ }
+
+ $key = 'ranges';
+ $object = $this->object();
+
+ /* Sample:
+
+ array(
+ 'monday' => 'eins',
+ 'tuesday' => '00:00-24:00',
+ 'sunday' => 'zwei',
+ );
+
+ */
+ if (array_key_exists($key, $values)) {
+ $object->ranges()->set($values[$key]);
+ unset($values[$key]);
+ }
+
+ foreach ($object->ranges()->getRanges() as $key => $value) {
+ $this->addRange($key, $value);
+ }
+ }
+
+ protected function addToCheckExecutionDisplayGroup($elements)
+ {
+ return $this->addElementsToGroup(
+ $elements,
+ 'check_execution',
+ self::GROUP_ORDER_CHECK_EXECUTION,
+ $this->translate('Check execution')
+ );
+ }
+
+ public function addElementsToGroup($elements, $group, $order, $legend = null)
+ {
+ if (! is_array($elements)) {
+ $elements = array($elements);
+ }
+
+ // These are optional elements, they might exist or not. We still want
+ // to see exception for other ones
+ $skipLegally = array('check_period_id');
+
+ $skip = array();
+ foreach ($elements as $k => $v) {
+ if (is_string($v)) {
+ $el = $this->getElement($v);
+ if (!$el && in_array($v, $skipLegally)) {
+ $skip[] = $k;
+ continue;
+ }
+
+ $elements[$k] = $el;
+ }
+ }
+
+ foreach ($skip as $k) {
+ unset($elements[$k]);
+ }
+
+ if (! array_key_exists($group, $this->displayGroups)) {
+ $this->addDisplayGroup($elements, $group, array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => $order,
+ 'legend' => $legend ?: $group,
+ ));
+ $this->displayGroups[$group] = $this->getDisplayGroup($group);
+ } else {
+ $this->displayGroups[$group]->addElements($elements);
+ }
+
+ return $this->displayGroups[$group];
+ }
+
+ protected function handleProperties(DbObject $object, &$values)
+ {
+ if ($this->hasBeenSent()) {
+ foreach ($values as $key => $value) {
+ try {
+ if ($key === 'imports' && ! empty($this->choiceElements)) {
+ if (! is_array($value)) {
+ $value = [$value];
+ }
+ foreach ($this->choiceElements as $element) {
+ $chosen = $element->getValue();
+ if (is_string($chosen)) {
+ $value[] = $chosen;
+ } elseif (is_array($chosen)) {
+ foreach ($chosen as $choice) {
+ $value[] = $choice;
+ }
+ }
+ }
+ }
+ $object->set($key, $value);
+ if ($object instanceof IcingaObject) {
+ if ($this->resolvedImports !== false) {
+ $object->imports()->getObjects();
+ }
+ }
+ } catch (Exception $e) {
+ $this->addException($e, $key);
+ }
+ }
+ }
+ }
+
+ protected function loadInheritedProperties()
+ {
+ if ($this->assertResolvedImports()) {
+ try {
+ $this->showInheritedProperties($this->object());
+ } catch (Exception $e) {
+ $this->addException($e);
+ }
+ }
+ }
+
+ protected function showInheritedProperties(IcingaObject $object)
+ {
+ $inherited = $object->getInheritedProperties();
+ $origins = $object->getOriginsProperties();
+
+ foreach ($inherited as $k => $v) {
+ if ($v !== null && $k !== 'object_name') {
+ $el = $this->getElement($k);
+ if ($el) {
+ $this->setInheritedValue($el, $inherited->$k, $origins->$k);
+ } elseif (substr($k, -3) === '_id') {
+ $k = substr($k, 0, -3);
+ $el = $this->getElement($k);
+ if ($el) {
+ $this->setInheritedValue(
+ $el,
+ $object->getRelatedObjectName($k, $v),
+ $origins->{"${k}_id"}
+ );
+ }
+ }
+ }
+ }
+ }
+
+ protected function prepareFields($object)
+ {
+ if ($this->assertResolvedImports()) {
+ $this->fieldLoader = new IcingaObjectFieldLoader($object);
+ $this->fieldLoader->prepareElements($this);
+ }
+
+ return $this;
+ }
+
+ protected function setCustomVarValues($values)
+ {
+ if ($this->fieldLoader) {
+ $this->fieldLoader->setValues($values, 'var_');
+ }
+
+ return $this;
+ }
+
+ protected function addFields()
+ {
+ if ($this->fieldLoader) {
+ $this->fieldLoader->addFieldsToForm($this);
+ $this->onAddedFields();
+ }
+ }
+
+ protected function onAddedFields()
+ {
+ }
+
+ // TODO: remove, used in sets I guess
+ protected function fieldLoader($object)
+ {
+ if ($this->fieldLoader === null) {
+ $this->fieldLoader = new IcingaObjectFieldLoader($object);
+ }
+
+ return $this->fieldLoader;
+ }
+
+ protected function isNew()
+ {
+ return $this->object === null || ! $this->object->hasBeenLoadedFromDb();
+ }
+
+ protected function setButtons()
+ {
+ if ($this->isNew()) {
+ $this->setSubmitLabel(
+ $this->translate('Add')
+ );
+ } else {
+ $this->setSubmitLabel(
+ $this->translate('Store')
+ );
+ $this->addDeleteButton();
+ }
+ }
+
+ /**
+ * @param bool $importsFirst
+ * @return $this
+ */
+ protected function groupMainProperties($importsFirst = false)
+ {
+ if ($importsFirst) {
+ $elements = [
+ 'imports',
+ 'object_type',
+ 'object_name',
+ ];
+ } else {
+ $elements = [
+ 'object_type',
+ 'object_name',
+ 'imports',
+ ];
+ }
+ $elements = array_merge($elements, [
+ 'display_name',
+ 'host',
+ 'host_id',
+ 'address',
+ 'address6',
+ 'groups',
+ 'inherited_groups',
+ 'applied_groups',
+ 'users',
+ 'user_groups',
+ 'apply_to',
+ 'command_id', // Notification
+ 'notification_interval',
+ 'period_id',
+ 'times_begin',
+ 'times_end',
+ 'email',
+ 'pager',
+ 'enable_notifications',
+ 'disable_checks', //Dependencies
+ 'disable_notifications',
+ 'ignore_soft_states',
+ 'apply_for',
+ 'create_live',
+ 'disabled',
+ ]);
+
+ // Add template choices to the main section
+ /** @var \Zend_Form_Element $el */
+ foreach ($this->getElements() as $key => $el) {
+ if (substr($el->getName(), 0, 6) === 'choice') {
+ $elements[] = $key;
+ }
+ }
+
+ $this->addDisplayGroup($elements, 'object_definition', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_OBJECT_DEFINITION,
+ 'legend' => $this->translate('Main properties')
+ ));
+
+ return $this;
+ }
+
+ protected function setSentValue($name, $value)
+ {
+ if ($this->hasBeenSent()) {
+ $request = $this->getRequest();
+ if ($value !== null && $request->isPost() && $request->getPost($name) !== null) {
+ $request->setPost($name, $value);
+ }
+ }
+
+ $this->setElementValue($name, $value);
+ }
+
+ public function setElementValue($name, $value = null)
+ {
+ $el = $this->getElement($name);
+ if (! $el) {
+ // Not showing an error, as most object properties do not exist. Not
+ // yet, because IMO this should be checked.
+ // $this->addError(sprintf($this->translate('Form element "%s" does not exist'), $name));
+ return;
+ }
+
+ if ($value !== null) {
+ $el->setValue($value);
+ }
+ }
+
+ public function setInheritedValue(ZfElement $el, $inherited, $inheritedFrom)
+ {
+ if ($inherited === null) {
+ return;
+ }
+
+ $txtInherited = sprintf($this->translate(' (inherited from "%s")'), $inheritedFrom);
+ if ($el instanceof ZfSelect) {
+ $multi = $el->getMultiOptions();
+ if (is_bool($inherited)) {
+ $inherited = $inherited ? 'y' : 'n';
+ }
+ if (is_scalar($inherited) && array_key_exists($inherited, $multi)) {
+ $multi[null] = $multi[$inherited] . $txtInherited;
+ } else {
+ $multi[null] = $this->stringifyInheritedValue($inherited) . $txtInherited;
+ }
+ $el->setMultiOptions($multi);
+ } elseif ($el instanceof ExtensibleSet) {
+ $el->setAttrib('inherited', $inherited);
+ $el->setAttrib('inheritedFrom', $inheritedFrom);
+ } else {
+ if (is_string($inherited) || is_int($inherited)) {
+ $el->setAttrib('placeholder', $inherited . $txtInherited);
+ }
+ }
+
+ // We inherited a value, so no need to require the field
+ $el->setRequired(false);
+ }
+
+ protected function stringifyInheritedValue($value)
+ {
+ return is_scalar($value) ? $value : substr(json_encode($value), 0, 40);
+ }
+
+ public function setListUrl($url)
+ {
+ $this->listUrl = $url;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $object = $this->object();
+ if ($object->hasBeenModified()) {
+ if (! $object->hasBeenLoadedFromDb()) {
+ $this->setHttpResponseCode(201);
+ }
+
+ $msg = sprintf(
+ $object->hasBeenLoadedFromDb()
+ ? $this->translate('The %s has successfully been stored')
+ : $this->translate('A new %s has successfully been created'),
+ $this->translate($this->getObjectShortClassName())
+ );
+ $this->getDbObjectStore()->store($object);
+ } else {
+ if ($this->isApiRequest()) {
+ $this->setHttpResponseCode(304);
+ }
+ $msg = $this->translate('No action taken, object has not been modified');
+ }
+
+ $this->setObjectSuccessUrl();
+ $this->beforeSuccessfulRedirect();
+ $this->redirectOnSuccess($msg);
+ }
+
+ protected function setObjectSuccessUrl()
+ {
+ $object = $this->object();
+
+ if ($object instanceof IcingaObject) {
+ $params = $object->getUrlParams();
+ $url = Url::fromPath($this->getAction());
+ if ($url->hasParam('dbResourceName')) {
+ $params['dbResourceName'] = $url->getParam('dbResourceName');
+ }
+ $this->setSuccessUrl(
+ 'director/' . strtolower($this->getObjectShortClassName()),
+ $params
+ );
+ } elseif ($object->hasProperty('id')) {
+ $this->setSuccessUrl($this->getSuccessUrl()->with('id', $object->getProperty('id')));
+ }
+ }
+
+ protected function beforeSuccessfulRedirect()
+ {
+ }
+
+ public function hasElement($name)
+ {
+ return $this->getElement($name) !== null;
+ }
+
+ public function getObject()
+ {
+ return $this->object;
+ }
+
+ public function hasObject()
+ {
+ return $this->object !== null;
+ }
+
+ public function isIcingaObject()
+ {
+ if ($this->object !== null) {
+ return $this->object instanceof IcingaObject;
+ }
+
+ /** @var DbObject $class */
+ $class = $this->getObjectClassname();
+ $instance = $class::create();
+
+ return $instance instanceof IcingaObject;
+ }
+
+ public function isMultiObjectForm()
+ {
+ return false;
+ }
+
+ public function setObject(DbObject $object)
+ {
+ $this->object = $object;
+ if ($this->db === null) {
+ /** @var Db $connection */
+ $connection = $object->getConnection();
+ $this->setDb($connection);
+ }
+
+ return $this;
+ }
+
+ protected function getObjectClassname()
+ {
+ if ($this->className === null) {
+ return 'Icinga\\Module\\Director\\Objects\\'
+ . substr(join('', array_slice(explode('\\', get_class($this)), -1)), 0, -4);
+ }
+
+ return $this->className;
+ }
+
+ protected function getObjectShortClassName()
+ {
+ if ($this->objectName === null) {
+ $className = substr(strrchr(get_class($this), '\\'), 1);
+ if (substr($className, 0, 6) === 'Icinga') {
+ return substr($className, 6, -4);
+ } else {
+ return substr($className, 0, -4);
+ }
+ }
+
+ return $this->objectName;
+ }
+
+ protected function removeFromSet(&$set, $key)
+ {
+ unset($set[$key]);
+ }
+
+ protected function moveUpInSet(&$set, $key)
+ {
+ list($set[$key - 1], $set[$key]) = array($set[$key], $set[$key - 1]);
+ }
+
+ protected function moveDownInSet(&$set, $key)
+ {
+ list($set[$key + 1], $set[$key]) = array($set[$key], $set[$key + 1]);
+ }
+
+ protected function beforeSetup()
+ {
+ if (!$this->hasBeenSent()) {
+ return;
+ }
+
+ $post = $values = $this->getRequest()->getPost();
+
+ foreach ($post as $key => $value) {
+ if (preg_match('/^(.+?)_(\d+)__(MOVE_DOWN|MOVE_UP|REMOVE)$/', $key, $m)) {
+ $values[$m[1]] = array_filter($values[$m[1]], 'strlen');
+ switch ($m[3]) {
+ case 'MOVE_UP':
+ $this->moveUpInSet($values[$m[1]], $m[2]);
+ break;
+ case 'MOVE_DOWN':
+ $this->moveDownInSet($values[$m[1]], $m[2]);
+ break;
+ case 'REMOVE':
+ $this->removeFromSet($values[$m[1]], $m[2]);
+ break;
+ }
+
+ $this->getRequest()->setPost($m[1], $values[$m[1]]);
+ }
+ }
+ }
+
+ protected function onRequest()
+ {
+ if ($this->object !== null) {
+ $this->setDefaultsFromObject($this->object);
+ }
+ $this->prepareFields($this->object());
+ IcingaObjectFormHook::callOnSetup($this);
+ if ($this->hasBeenSent()) {
+ $this->handlePost();
+ }
+ try {
+ $this->loadInheritedProperties();
+ $this->addFields();
+ $this->callOnRequestCallables();
+ } catch (Exception $e) {
+ $this->addUniqueException($e);
+
+ return;
+ }
+
+ if ($this->shouldBeDeleted()) {
+ $this->deleteObject($this->object());
+ }
+ }
+
+ protected function handlePost()
+ {
+ $object = $this->object();
+
+ $post = $this->getRequest()->getPost();
+ $this->populate($post);
+ $values = $this->getValues();
+
+ if ($object instanceof IcingaObject) {
+ $this->setCustomVarValues($post);
+ }
+
+ $this->handleProperties($object, $values);
+
+ // TODO: get rid of this
+ if ($object instanceof IcingaObject) {
+ $this->handleRanges($object, $values);
+ }
+ }
+
+ protected function setDefaultsFromObject(DbObject $object)
+ {
+ /** @var ZfElement $element */
+ foreach ($this->getElements() as $element) {
+ $key = $element->getName();
+ if ($object->hasProperty($key)) {
+ $value = $object->get($key);
+ if ($object instanceof IcingaObject) {
+ if ($object->propertyIsRelatedSet($key)) {
+ if (! count((array) $value)) {
+ continue;
+ }
+ }
+ }
+
+ if ($value !== null && $value !== []) {
+ $element->setValue($value);
+ }
+ }
+ }
+ }
+
+ protected function deleteObject($object)
+ {
+ if ($object instanceof IcingaObject && $object->hasProperty('object_name')) {
+ $msg = sprintf(
+ '%s "%s" has been removed',
+ $this->translate($this->getObjectShortClassName()),
+ $object->getObjectName()
+ );
+ } else {
+ $msg = sprintf(
+ '%s has been removed',
+ $this->translate($this->getObjectShortClassName())
+ );
+ }
+
+ if ($this->listUrl) {
+ $url = $this->listUrl;
+ } elseif ($object instanceof IcingaObject && $object->hasProperty('object_name')) {
+ $url = $object->getOnDeleteUrl();
+ } else {
+ $url = $this->getSuccessUrl()->without(
+ array('field_id', 'argument_id', 'range', 'range_type')
+ );
+ }
+
+ if ($this->getDbObjectStore()->delete($object)) {
+ $this->setSuccessUrl($url);
+ }
+ $this->redirectOnSuccess($msg);
+ }
+
+ /**
+ * @return DbObjectStore
+ */
+ protected function getDbObjectStore()
+ {
+ $store = new DbObjectStore($this->getDb(), $this->branch);
+ return $store;
+ }
+
+ protected function addDeleteButton($label = null)
+ {
+ $object = $this->object;
+
+ if ($label === null) {
+ $label = $this->translate('Delete');
+ }
+
+ $el = $this->createElement('submit', $label)
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ //->removeDecorator('Label');
+
+ $this->deleteButtonName = $el->getName();
+
+ if ($object instanceof IcingaObject && $object->isTemplate()) {
+ if ($cnt = $object->countDirectDescendants()) {
+ $el->setAttrib('disabled', 'disabled');
+ $el->setAttrib(
+ 'title',
+ sprintf(
+ $this->translate('This template is still in use by %d other objects'),
+ $cnt
+ )
+ );
+ }
+ } elseif ($object instanceof IcingaCommand && $object->isInUse()) {
+ $el->setAttrib('disabled', 'disabled');
+ $el->setAttrib(
+ 'title',
+ sprintf(
+ $this->translate('This Command is still in use by %d other objects'),
+ $object->countDirectUses()
+ )
+ );
+ }
+
+ $this->addElement($el);
+
+ return $this;
+ }
+
+ 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();
+ }
+
+ public function abortDeletion()
+ {
+ if ($this->hasDeleteButton()) {
+ $this->setSentValue($this->deleteButtonName, 'ABORTED');
+ }
+ }
+
+ public function getSentOrResolvedObjectValue($name, $default = null)
+ {
+ return $this->getSentOrObjectValue($name, $default, true);
+ }
+
+ public function getSentOrObjectValue($name, $default = null, $resolved = false)
+ {
+ // TODO: check whether getSentValue is still needed since element->getValue
+ // is in place (currently for form element default values only)
+
+ if (!$this->hasObject()) {
+ if ($this->hasBeenSent()) {
+ return $this->getSentValue($name, $default);
+ } else {
+ if ($name === 'object_type' && $this->preferredObjectType) {
+ return $this->preferredObjectType;
+ }
+ if ($name === 'imports' && $this->presetImports) {
+ return $this->presetImports;
+ }
+ if ($this->valueIsEmpty($val = $this->getValue($name))) {
+ return $default;
+ } else {
+ return $val;
+ }
+ }
+ }
+
+ if ($this->hasBeenSent()) {
+ if (!$this->valueIsEmpty($value = $this->getSentValue($name))) {
+ return $value;
+ }
+ }
+
+ $object = $this->getObject();
+
+ if ($object->hasProperty($name)) {
+ if ($resolved && $object->supportsImports()) {
+ if ($this->assertResolvedImports()) {
+ $objectProperty = $object->getResolvedProperty($name);
+ } else {
+ $objectProperty = $object->$name;
+ }
+ } else {
+ $objectProperty = $object->$name;
+ }
+ } else {
+ $objectProperty = null;
+ }
+
+ if ($objectProperty !== null) {
+ return $objectProperty;
+ }
+
+ if (($el = $this->getElement($name)) && !$this->valueIsEmpty($val = $el->getValue())) {
+ return $val;
+ }
+
+ return $default;
+ }
+
+ public function loadObject($id)
+ {
+ if ($this->branch && $this->branch->isBranch()) {
+ throw new \RuntimeException('Calling loadObject from form in a branch');
+ }
+ /** @var DbObject $class */
+ $class = $this->getObjectClassname();
+ if (is_int($id)) {
+ $this->object = $class::loadWithAutoIncId($id, $this->db);
+ if ($this->object->getKeyName() === 'id') {
+ $this->addHidden('id', $id);
+ }
+ } else {
+ $this->object = $class::load($id, $this->db);
+ }
+
+
+ return $this;
+ }
+
+ protected function addRange($key, $range)
+ {
+ $this->addElement('text', 'range_' . $key, array(
+ 'label' => 'ranges.' . $key,
+ 'value' => $range->range_value
+ ));
+ }
+
+ /**
+ * @param Db $db
+ * @return $this
+ */
+ public function setDb(Db $db)
+ {
+ if ($this->object !== null) {
+ $this->object->setConnection($db);
+ }
+
+ parent::setDb($db);
+ return $this;
+ }
+
+ public function optionallyAddFromEnum($enum)
+ {
+ return array(
+ null => $this->translate('- click to add more -')
+ ) + $enum;
+ }
+
+ protected function addObjectTypeElement()
+ {
+ if (!$this->isNew()) {
+ return $this;
+ }
+
+ if ($this->preferredObjectType) {
+ $this->addHidden('object_type', $this->preferredObjectType);
+ return $this;
+ }
+
+ $object = $this->object();
+
+ if ($object->supportsImports()) {
+ $templates = $this->enumAllowedTemplates();
+
+ if (empty($templates) && $this->getObjectShortClassName() !== 'Command') {
+ $types = array('template' => $this->translate('Template'));
+ } else {
+ $types = array(
+ 'object' => $this->translate('Object'),
+ 'template' => $this->translate('Template'),
+ );
+ }
+ } else {
+ $types = array('object' => $this->translate('Object'));
+ }
+
+ if ($this->object()->supportsApplyRules()) {
+ $types['apply'] = $this->translate('Apply rule');
+ }
+
+ $this->addElement('select', 'object_type', array(
+ 'label' => $this->translate('Object type'),
+ 'description' => $this->translate(
+ 'What kind of object this should be. Templates allow full access'
+ . ' to any property, they are your building blocks for "real" objects.'
+ . ' External objects should usually not be manually created or modified.'
+ . ' They allow you to work with objects locally defined on your Icinga nodes,'
+ . ' while not rendering and deploying them with the Director. Apply rules allow'
+ . ' to assign services, notifications and groups to other objects.'
+ ),
+ 'required' => true,
+ 'multiOptions' => $this->optionalEnum($types),
+ 'class' => 'autosubmit'
+ ));
+
+ return $this;
+ }
+
+ protected function hasObjectType()
+ {
+ if (!$this->object()->hasProperty('object_type')) {
+ return false;
+ }
+
+ return ! $this->valueIsEmpty($this->getSentOrObjectValue('object_type'));
+ }
+
+ protected function addZoneElement($all = false)
+ {
+ if ($all || $this->isTemplate()) {
+ $zones = $this->db->enumZones();
+ } else {
+ $zones = $this->db->enumNonglobalZones();
+ }
+
+ $this->addElement('select', 'zone_id', array(
+ 'label' => $this->translate('Cluster Zone'),
+ 'description' => $this->translate(
+ 'Icinga cluster zone. Allows to manually override Directors decisions'
+ . ' of where to deploy your config to. You should consider not doing so'
+ . ' unless you gained deep understanding of how an Icinga Cluster stack'
+ . ' works'
+ ),
+ 'multiOptions' => $this->optionalEnum($zones)
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @param $type
+ * @return $this
+ */
+ protected function addChoices($type)
+ {
+ if ($this->isTemplate()) {
+ return $this;
+ }
+
+ $connection = $this->getDb();
+ $choiceType = 'TemplateChoice' . ucfirst($type);
+ $table = "icinga_$type";
+ $choices = IcingaObject::loadAllByType($choiceType, $connection);
+ $chosenTemplates = $this->getSentOrObjectValue('imports');
+ $db = $connection->getDbAdapter();
+ if (empty($chosenTemplates)) {
+ $importedIds = [];
+ } else {
+ $importedIds = $db->fetchCol(
+ $db->select()->from($table, 'id')
+ ->where('object_name in (?)', (array)$chosenTemplates)
+ ->where('object_type = ?', 'template')
+ );
+ }
+
+ foreach ($choices as $choice) {
+ $required = $choice->get('required_template_id');
+ if ($required === null || in_array($required, $importedIds, false)) {
+ $this->addChoiceElement($choice);
+ }
+ }
+
+ return $this;
+ }
+
+ protected function addChoiceElement(IcingaTemplateChoice $choice)
+ {
+ $imports = $this->object()->listImportNames();
+ $element = $choice->createFormElement($this, $imports);
+ $this->addElement($element);
+ $this->choiceElements[$element->getName()] = $element;
+ return $this;
+ }
+
+ /**
+ * @param bool $required
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addImportsElement($required = null)
+ {
+ if ($this->presetImports) {
+ return $this;
+ }
+
+ if (in_array($this->getObjectShortClassName(), ['TimePeriod', 'ScheduledDowntime'])) {
+ $required = false;
+ } else {
+ $required = $required !== null ? $required : !$this->isTemplate();
+ }
+ $enum = $this->enumAllowedTemplates();
+ if (empty($enum)) {
+ if ($required) {
+ if ($this->hasBeenSent()) {
+ $this->addError($this->translate('No template has been chosen'));
+ } else {
+ if ($this->hasPermission('director/admin')) {
+ $html = $this->translate('Please define a related template first');
+ } else {
+ $html = $this->translate('No related template has been provided yet');
+ }
+ $this->addHtml('<p class="warning">' . $html . '</p>');
+ }
+ }
+ return $this;
+ }
+
+ $db = $this->getDb()->getDbAdapter();
+ $object = $this->object;
+ if ($object->supportsChoices()) {
+ $choiceNames = $db->fetchCol(
+ $db->select()->from(
+ $this->object()->getTableName(),
+ 'object_name'
+ )->where('template_choice_id IS NOT NULL')
+ );
+ } else {
+ $choiceNames = [];
+ }
+
+ $type = $object->getShortTableName();
+ $this->addElement('extensibleSet', 'imports', array(
+ 'label' => $this->translate('Imports'),
+ 'description' => $this->translate(
+ 'Importable templates, add as many as you want. Please note that order'
+ . ' matters when importing properties from multiple templates: last one'
+ . ' wins'
+ ),
+ 'required' => $required,
+ 'spellcheck' => 'false',
+ 'hideOptions' => $choiceNames,
+ 'suggest' => "${type}templates",
+ // 'multiOptions' => $this->optionallyAddFromEnum($enum),
+ 'sorted' => true,
+ 'value' => $this->presetImports,
+ 'class' => 'autosubmit'
+ ));
+
+ return $this;
+ }
+
+ protected function addDisabledElement()
+ {
+ if ($this->isTemplate()) {
+ return $this;
+ }
+
+ $this->addBoolean(
+ 'disabled',
+ array(
+ 'label' => $this->translate('Disabled'),
+ 'description' => $this->translate('Disabled objects will not be deployed')
+ ),
+ 'n'
+ );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addGroupDisplayNameElement()
+ {
+ $this->addElement('text', 'display_name', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'An alternative display name for this group. If you wonder how this'
+ . ' could be helpful just leave it blank'
+ )
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @param bool $force
+ *
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addCheckCommandElements($force = false)
+ {
+ if (! $force && ! $this->isTemplate()) {
+ return $this;
+ }
+
+ $this->addElement('text', 'check_command', array(
+ 'label' => $this->translate('Check command'),
+ 'description' => $this->translate('Check command definition'),
+ // 'multiOptions' => $this->optionalEnum($this->db->enumCheckcommands()),
+ 'class' => 'autosubmit director-suggest', // This influences fields
+ 'data-suggestion-context' => 'checkcommandnames',
+ 'value' => $this->getObject()->get('check_command')
+ ));
+ $this->getDisplayGroup('object_definition')
+ // ->addElement($this->getElement('check_command_id'))
+ ->addElement($this->getElement('check_command'));
+
+ $eventCommands = $this->db->enumEventcommands();
+
+ if (! empty($eventCommands)) {
+ $this->addElement('select', 'event_command_id', array(
+ 'label' => $this->translate('Event command'),
+ 'description' => $this->translate('Event command definition'),
+ 'multiOptions' => $this->optionalEnum($eventCommands),
+ 'class' => 'autosubmit',
+ ));
+ $this->addToCheckExecutionDisplayGroup('event_command_id');
+ }
+
+ return $this;
+ }
+
+ protected function addCheckExecutionElements($force = false)
+ {
+ if (! $force && ! $this->isTemplate()) {
+ return $this;
+ }
+
+ $this->addElement(
+ 'text',
+ 'check_interval',
+ array(
+ 'label' => $this->translate('Check interval'),
+ 'description' => $this->translate('Your regular check interval')
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'retry_interval',
+ array(
+ 'label' => $this->translate('Retry interval'),
+ 'description' => $this->translate(
+ 'Retry interval, will be applied after a state change unless the next hard state is reached'
+ )
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'max_check_attempts',
+ array(
+ 'label' => $this->translate('Max check attempts'),
+ 'description' => $this->translate(
+ 'Defines after how many check attempts a new hard state is reached'
+ )
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'check_timeout',
+ array(
+ 'label' => $this->translate('Check timeout'),
+ 'description' => $this->translate(
+ "Check command timeout in seconds. Overrides the CheckCommand's timeout attribute"
+ )
+ )
+ );
+
+ $periods = $this->db->enumTimeperiods();
+
+ if (!empty($periods)) {
+ $this->addElement(
+ 'select',
+ 'check_period_id',
+ array(
+ 'label' => $this->translate('Check period'),
+ 'description' => $this->translate(
+ 'The name of a time period which determines when this'
+ . ' object should be monitored. Not limited by default.'
+ ),
+ 'multiOptions' => $this->optionalEnum($periods),
+ )
+ );
+ }
+
+ $this->optionalBoolean(
+ 'enable_active_checks',
+ $this->translate('Execute active checks'),
+ $this->translate('Whether to actively check this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_passive_checks',
+ $this->translate('Accept passive checks'),
+ $this->translate('Whether to accept passive check results for this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_notifications',
+ $this->translate('Send notifications'),
+ $this->translate('Whether to send notifications for this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_event_handler',
+ $this->translate('Enable event handler'),
+ $this->translate('Whether to enable event handlers this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_perfdata',
+ $this->translate('Process performance data'),
+ $this->translate('Whether to process performance data provided by this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_flapping',
+ $this->translate('Enable flap detection'),
+ $this->translate('Whether flap detection is enabled on this object')
+ );
+
+ $this->addElement(
+ 'text',
+ 'flapping_threshold_high',
+ array(
+ 'label' => $this->translate('Flapping threshold (high)'),
+ 'description' => $this->translate(
+ 'Flapping upper bound in percent for a service to be considered flapping'
+ )
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'flapping_threshold_low',
+ array(
+ 'label' => $this->translate('Flapping threshold (low)'),
+ 'description' => $this->translate(
+ 'Flapping lower bound in percent for a service to be considered not flapping'
+ )
+ )
+ );
+
+ $this->optionalBoolean(
+ 'volatile',
+ $this->translate('Volatile'),
+ $this->translate('Whether this check is volatile.')
+ );
+
+ $elements = array(
+ 'check_interval',
+ 'retry_interval',
+ 'max_check_attempts',
+ 'check_timeout',
+ 'check_period_id',
+ 'enable_active_checks',
+ 'enable_passive_checks',
+ 'enable_notifications',
+ 'enable_event_handler',
+ 'enable_perfdata',
+ 'enable_flapping',
+ 'flapping_threshold_high',
+ 'flapping_threshold_low',
+ 'volatile'
+ );
+ $this->addToCheckExecutionDisplayGroup($elements);
+
+ return $this;
+ }
+
+ protected function enumAllowedTemplates()
+ {
+ $object = $this->object();
+ $tpl = $this->db->enumIcingaTemplates($object->getShortTableName());
+ if (empty($tpl)) {
+ return [];
+ }
+
+ $id = $object->get('id');
+
+ if (array_key_exists($id, $tpl)) {
+ unset($tpl[$id]);
+ }
+
+ return array_combine($tpl, $tpl);
+ }
+
+ protected function addExtraInfoElements()
+ {
+ $this->addElement('textarea', 'notes', array(
+ 'label' => $this->translate('Notes'),
+ 'description' => $this->translate(
+ 'Additional notes for this object'
+ ),
+ 'rows' => 2,
+ 'columns' => 60,
+ ));
+
+ $this->addElement('text', 'notes_url', array(
+ 'label' => $this->translate('Notes URL'),
+ 'description' => $this->translate(
+ 'An URL pointing to additional notes for this object'
+ ),
+ ));
+
+ $this->addElement('text', 'action_url', array(
+ 'label' => $this->translate('Action URL'),
+ 'description' => $this->translate(
+ 'An URL leading to additional actions for this object. Often used'
+ . ' with Icinga Classic, rarely with Icinga Web 2 as it provides'
+ . ' far better possibilities to integrate addons'
+ ),
+ ));
+
+ $this->addElement('text', 'icon_image', array(
+ 'label' => $this->translate('Icon image'),
+ 'description' => $this->translate(
+ 'An URL pointing to an icon for this object. Try "tux.png" for icons'
+ . ' relative to public/img/icons or "cloud" (no extension) for items'
+ . ' from the Icinga icon font'
+ ),
+ ));
+
+ $this->addElement('text', 'icon_image_alt', array(
+ 'label' => $this->translate('Icon image alt'),
+ 'description' => $this->translate(
+ 'Alternative text to be shown in case above icon is missing'
+ ),
+ ));
+
+ $elements = array(
+ 'notes',
+ 'notes_url',
+ 'action_url',
+ 'icon_image',
+ 'icon_image_alt',
+ );
+
+ $this->addDisplayGroup($elements, 'extrainfo', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_EXTRA_INFO,
+ 'legend' => $this->translate('Additional properties')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Add an assign_filter form element
+ *
+ * Forms should use this helper method for objects using the typical
+ * assign_filter column
+ *
+ * @param array $properties Form element properties
+ *
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addAssignFilter($properties)
+ {
+ if (!$this->object || !$this->object->supportsAssignments()) {
+ return $this;
+ }
+
+ $this->addFilterElement('assign_filter', $properties);
+ $el = $this->getElement('assign_filter');
+
+ $this->addDisplayGroup(array($el), 'assign', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_ASSIGN,
+ 'legend' => $this->translate('Assign where')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Add a dataFilter element with fitting decorators
+ *
+ * TODO: Evaluate whether parts or all of this could be moved to the element
+ * class.
+ *
+ * @param string $name Element name
+ * @param array $properties Form element properties
+ *
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addFilterElement($name, $properties)
+ {
+ $this->addElement('dataFilter', $name, $properties);
+ $el = $this->getElement($name);
+
+ $ddClass = 'full-width';
+ if (array_key_exists('required', $properties) && $properties['required']) {
+ $ddClass .= ' required';
+ }
+
+ $el->clearDecorators()
+ ->addDecorator('ViewHelper')
+ ->addDecorator('Errors')
+ ->addDecorator('Description', array('tag' => 'p', 'class' => 'description'))
+ ->addDecorator('HtmlTag', array(
+ 'tag' => 'dd',
+ 'class' => $ddClass,
+ ));
+
+ return $this;
+ }
+
+ protected function addEventFilterElements($elements = array('states','types'))
+ {
+ if (in_array('states', $elements)) {
+ $this->addElement('extensibleSet', 'states', array(
+ 'label' => $this->translate('States'),
+ 'multiOptions' => $this->optionallyAddFromEnum($this->enumStates()),
+ 'description' => $this->translate(
+ 'The host/service states you want to get notifications for'
+ ),
+ ));
+ }
+
+ if (in_array('types', $elements)) {
+ $this->addElement('extensibleSet', 'types', array(
+ 'label' => $this->translate('Transition types'),
+ 'multiOptions' => $this->optionallyAddFromEnum($this->enumTypes()),
+ 'description' => $this->translate(
+ 'The state transition types you want to get notifications for'
+ ),
+ ));
+ }
+
+ $this->addDisplayGroup($elements, 'event_filters', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_EVENT_FILTERS,
+ 'legend' => $this->translate('State and transition type filters')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @param string $permission
+ * @return bool
+ */
+ public function hasPermission($permission)
+ {
+ return Util::hasPermission($permission);
+ }
+
+ public function setBranch(Branch $branch)
+ {
+ $this->branch = $branch;
+
+ return $this;
+ }
+
+ protected function allowsExperimental()
+ {
+ // NO, it is NOT a good idea to use this. You'll break your monitoring
+ // and nobody will help you.
+ if ($this->allowsExperimental === null) {
+ $this->allowsExperimental = $this->db->settings()->get(
+ 'experimental_features'
+ ) === 'allow';
+ }
+
+ return $this->allowsExperimental;
+ }
+
+ protected function enumStates()
+ {
+ $set = new StateFilterSet();
+ return $set->enumAllowedValues();
+ }
+
+ protected function enumTypes()
+ {
+ $set = new TypeFilterSet();
+ return $set->enumAllowedValues();
+ }
+}
diff --git a/library/Director/Web/Form/Element/Boolean.php b/library/Director/Web/Form/Element/Boolean.php
new file mode 100644
index 0000000..b2402c7
--- /dev/null
+++ b/library/Director/Web/Form/Element/Boolean.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Select as ZfSelect;
+
+/**
+ * Input control for booleans
+ */
+class Boolean extends ZfSelect
+{
+ public $options = array(
+ null => '- please choose -',
+ 'y' => 'Yes',
+ 'n' => 'No',
+ );
+
+ public function getValue()
+ {
+ $value = $this->getUnfilteredValue();
+
+ if ($value === 'y' || $value === true) {
+ return true;
+ } elseif ($value === 'n' || $value === false) {
+ return false;
+ }
+
+ return null;
+ }
+
+ public function isValid($value, $context = null)
+ {
+ if ($value === 'y' || $value === 'n') {
+ $this->setValue($value);
+ return true;
+ }
+
+ return parent::isValid($value, $context);
+ }
+
+ /**
+ * @param string $value
+ * @param string $key
+ * @codingStandardsIgnoreStart
+ */
+ protected function _filterValue(&$value, &$key)
+ {
+ // @codingStandardsIgnoreEnd
+ if ($value === true) {
+ $value = 'y';
+ } elseif ($value === false) {
+ $value = 'n';
+ } elseif ($value === '') {
+ $value = null;
+ }
+
+ parent::_filterValue($value, $key);
+ }
+
+ public function setValue($value)
+ {
+ if ($value === true) {
+ $value = 'y';
+ } elseif ($value === false) {
+ $value = 'n';
+ } elseif ($value === '') {
+ $value = null;
+ }
+
+ return parent::setValue($value);
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ protected function _translateOption($option, $value)
+ {
+ // @codingStandardsIgnoreEnd
+ if (!isset($this->_translated[$option]) && !empty($value)) {
+ $this->options[$option] = mt('director', $value);
+ if ($this->options[$option] === $value) {
+ return false;
+ }
+ $this->_translated[$option] = true;
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Director/Web/Form/Element/DataFilter.php b/library/Director/Web/Form/Element/DataFilter.php
new file mode 100644
index 0000000..adae07d
--- /dev/null
+++ b/library/Director/Web/Form/Element/DataFilter.php
@@ -0,0 +1,361 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Web\Form\IconHelper;
+use Exception;
+
+/**
+ * Input control for extensible sets
+ */
+class DataFilter extends FormElement
+{
+ /**
+ * Default form view helper to use for rendering
+ * @var string
+ */
+ public $helper = 'formDataFilter';
+
+ private $addTo;
+
+ private $removeFilter;
+
+ private $stripFilter;
+
+ /** @var FilterChain */
+ private $filter;
+
+ public function getValue()
+ {
+ $value = parent::getValue();
+ if ($value !== null && $this->isEmpty($value)) {
+ $value = null;
+ }
+
+ return $value;
+ }
+
+ protected function isEmpty(Filter $filter)
+ {
+ return $filter->isEmpty() || $this->isEmptyExpression($filter);
+ }
+
+ protected function isEmptyExpression(Filter $filter)
+ {
+ return $filter instanceof FilterExpression &&
+ $filter->getColumn() === '' &&
+ $filter->getExpression() === '""'; // -> json_encode('')
+ }
+
+ /**
+ * @inheritdoc
+ * @codingStandardsIgnoreStart
+ */
+ protected function _filterValue(&$value, &$key)
+ {
+ // @codingStandardsIgnoreEnd
+ try {
+ if ($value instanceof Filter) {
+ // OK
+ } elseif (is_string($value)) {
+ $value = Filter::fromQueryString($value);
+ } elseif (is_array($value) || is_null($value)) {
+ $value = $this->arrayToFilter($value);
+ } else {
+ throw new ProgrammingError(
+ 'Value to be filtered has to be Filter, string, array or null'
+ );
+ }
+ } catch (Exception $e) {
+ $value = null;
+ // TODO: getFile, getLine
+ // Hint: cannot addMessage at it would loop through getValue
+ $this->addErrorMessage($e->getMessage());
+ $this->_isErrorForced = true;
+ }
+ }
+
+ /**
+ * This method transforms filter form data into a filter
+ * and reacts on pressed buttons
+ *
+ * @param array|null $array
+ *
+ * @return FilterChain|null
+ */
+ protected function arrayToFilter($array)
+ {
+ if ($array === null) {
+ return null;
+ }
+
+ $this->filter = null;
+ foreach ($array as $id => $entry) {
+ $filterId = $this->idToFilterId($id);
+ $sub = $this->entryToFilter($entry);
+ $this->checkEntryForActions($filterId, $entry);
+ $parentId = $this->parentIdFor($filterId);
+
+ if ($this->filter === null) {
+ $this->filter = $sub;
+ } else {
+ $this->filter->getById($parentId)->addFilter($sub);
+ }
+ }
+
+ $this->removeFilterIfRequested()
+ ->stripFilterIfRequested()
+ ->addNewFilterIfRequested()
+ ->fixNotsWithMultipleChildren();
+
+ return $this->filter;
+ }
+
+ protected function removeFilterIfRequested()
+ {
+ if ($this->removeFilter !== null) {
+ if ($this->filter->getById($this->removeFilter)->isRootNode()) {
+ $this->filter = $this->emptyExpression();
+ } else {
+ $this->filter->removeId($this->removeFilter);
+ }
+ }
+
+ return $this;
+ }
+
+
+ protected function stripFilterIfRequested()
+ {
+ if ($this->stripFilter !== null) {
+ $strip = $this->stripFilter;
+ $subId = $strip . '-1';
+ if ($this->filter->getId() === $strip) {
+ $this->filter = $this->filter->getById($subId);
+ } else {
+ $this->filter->replaceById($strip, $this->filter->getById($subId));
+ }
+ }
+
+ return $this;
+ }
+
+ protected function addNewFilterIfRequested()
+ {
+ if ($this->addTo !== null) {
+ $parent = $this->filter->getById($this->addTo);
+
+ if ($parent instanceof FilterChain) {
+ if ($parent->isEmpty()) {
+ $parent->addFilter($this->emptyExpression());
+ } else {
+ $parent->addFilter($this->emptyExpression());
+ }
+ } elseif ($parent instanceof FilterExpression) {
+ $replacement = Filter::matchAll(clone($parent));
+ if ($parent->isRootNode()) {
+ $this->filter = $replacement;
+ } else {
+ $this->filter->replaceById($parent->getId(), $replacement);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ protected function fixNotsWithMultipleChildren()
+ {
+ $this->filter = $this->fixNotsWithMultipleChildrenForFilter($this->filter);
+ return $this;
+ }
+
+ protected function fixNotsWithMultipleChildrenForFilter(Filter $filter)
+ {
+ if ($filter instanceof FilterChain) {
+ if ($filter->getOperatorName() === 'NOT') {
+ if ($filter->count() > 1) {
+ $filter = $this->notToNotAnd($filter);
+ }
+ }
+ /** @var Filter $sub */
+ foreach ($filter->filters() as $sub) {
+ $filter->replaceById(
+ $sub->getId(),
+ $this->fixNotsWithMultipleChildrenForFilter($sub)
+ );
+ }
+ }
+
+ return $filter;
+ }
+
+ protected function notToNotAnd(FilterChain $not)
+ {
+ $and = Filter::matchAll();
+ foreach ($not->filters() as $sub) {
+ $and->addFilter(clone($sub));
+ }
+
+ return Filter::not($and);
+ }
+
+ protected function emptyExpression()
+ {
+ return Filter::expression('', '=', '');
+ }
+
+ protected function parentIdFor($id)
+ {
+ if (false === ($pos = strrpos($id, '-'))) {
+ return '0';
+ } else {
+ return substr($id, 0, $pos);
+ }
+ }
+
+ protected function idToFilterId($id)
+ {
+ if (! preg_match('/^id_(new_)?(\d+(?:-\d+)*)$/', $id, $m)) {
+ die('nono' . $id);
+ }
+
+ return $m[2];
+ }
+
+ protected function checkEntryForActions($filterId, $entry)
+ {
+ switch ($this->entryAction($entry)) {
+ case 'cancel':
+ $this->removeFilter = $filterId;
+ break;
+
+ case 'minus':
+ $this->stripFilter = $filterId;
+ break;
+
+ case 'plus':
+ case 'angle-double-right':
+ $this->addTo = $filterId;
+ break;
+ }
+ }
+
+ /**
+ * Transforms a single submitted form component from an array
+ * into a Filter object
+ *
+ * @param array $entry The array as submitted through the form
+ *
+ * @return Filter
+ */
+ protected function entryToFilter($entry)
+ {
+ if (array_key_exists('operator', $entry)) {
+ return Filter::chain($entry['operator']);
+ } else {
+ return $this->entryToFilterExpression($entry);
+ }
+ }
+
+ protected function entryToFilterExpression($entry)
+ {
+ if ($entry['sign'] === 'true') {
+ return Filter::expression(
+ $entry['column'],
+ '=',
+ json_encode(true)
+ );
+ } elseif ($entry['sign'] === 'false') {
+ return Filter::expression(
+ $entry['column'],
+ '=',
+ json_encode(false)
+ );
+ } elseif ($entry['sign'] === 'in') {
+ if (array_key_exists('value', $entry)) {
+ if (is_array($entry['value'])) {
+ $value = array_filter($entry['value'], 'strlen');
+ } elseif (empty($entry['value'])) {
+ $value = array();
+ } else {
+ $value = array($entry['value']);
+ }
+ } else {
+ $value = array();
+ }
+ return Filter::expression(
+ $entry['column'],
+ '=',
+ json_encode($value)
+ );
+ } elseif ($entry['sign'] === 'contains') {
+ $value = array_key_exists('value', $entry) ? $entry['value'] : null;
+
+ return Filter::expression(
+ json_encode($value),
+ '=',
+ $entry['column']
+ );
+ } else {
+ $value = array_key_exists('value', $entry) ? $entry['value'] : null;
+
+ return Filter::expression(
+ $entry['column'],
+ $entry['sign'],
+ json_encode($value)
+ );
+ }
+ }
+
+ protected function entryAction($entry)
+ {
+ if (array_key_exists('action', $entry)) {
+ return IconHelper::instance()->characterIconName($entry['action']);
+ }
+
+ return null;
+ }
+
+ protected function hasIncompleteExpressions(Filter $filter)
+ {
+ if ($filter instanceof FilterChain) {
+ foreach ($filter->filters() as $sub) {
+ if ($this->hasIncompleteExpressions($sub)) {
+ return true;
+ }
+ }
+
+ return false;
+ } else {
+ /** @var FilterExpression $filter */
+ if ($filter->isRootNode() && $this->isEmptyExpression($filter)) {
+ return false;
+ }
+
+ return $filter->getColumn() === '';
+ }
+ }
+
+ public function isValid($value, $context = null)
+ {
+ if (! $value instanceof Filter) {
+ // TODO: try, return false on E
+ $filter = $this->arrayToFilter($value);
+ $this->setValue($filter);
+ } else {
+ $filter = $value;
+ }
+
+ if ($this->hasIncompleteExpressions($filter)) {
+ $this->addError('The configured filter is incomplete');
+ return false;
+ }
+
+ return parent::isValid($value);
+ }
+}
diff --git a/library/Director/Web/Form/Element/ExtensibleSet.php b/library/Director/Web/Form/Element/ExtensibleSet.php
new file mode 100644
index 0000000..f3c968f
--- /dev/null
+++ b/library/Director/Web/Form/Element/ExtensibleSet.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use InvalidArgumentException;
+
+/**
+ * Input control for extensible sets
+ */
+class ExtensibleSet extends FormElement
+{
+ /**
+ * Default form view helper to use for rendering
+ * @var string
+ */
+ public $helper = 'formIplExtensibleSet';
+
+ // private $multiOptions;
+
+ public function getValue()
+ {
+ $value = parent::getValue();
+ if (is_string($value) || is_numeric($value)) {
+ $value = [$value];
+ } elseif ($value === null) {
+ return $value;
+ }
+ if (! is_array($value)) {
+ throw new InvalidArgumentException(sprintf(
+ 'ExtensibleSet expects to work with Arrays, got %s',
+ var_export($value, 1)
+ ));
+ }
+ $value = array_filter($value, 'strlen');
+
+ if (empty($value)) {
+ return null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * We do not want one message per entry
+ *
+ * @codingStandardsIgnoreStart
+ */
+ protected function _getErrorMessages()
+ {
+ return $this->_errorMessages;
+ // @codingStandardsIgnoreEnd
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ protected function _filterValue(&$value, &$key)
+ {
+ // @codingStandardsIgnoreEnd
+ if (is_array($value)) {
+ $value = array_filter($value, 'strlen');
+ } elseif (is_string($value) && !strlen($value)) {
+ $value = null;
+ }
+
+ parent::_filterValue($value, $key);
+ }
+
+ public function isValid($value, $context = null)
+ {
+ if ($value === null) {
+ $value = [];
+ }
+
+ $value = array_filter($value, 'strlen');
+ $this->setValue($value);
+ if ($this->isRequired() && empty($value)) {
+ // TODO: translate
+ $this->addError('You are required to choose at least one element');
+ return false;
+ }
+
+ if ($this->hasErrors()) {
+ return false;
+ }
+
+ return parent::isValid($value, $context);
+ }
+}
diff --git a/library/Director/Web/Form/Element/FormElement.php b/library/Director/Web/Form/Element/FormElement.php
new file mode 100644
index 0000000..c327859
--- /dev/null
+++ b/library/Director/Web/Form/Element/FormElement.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Xhtml;
+
+class FormElement extends Zend_Form_Element_Xhtml
+{
+}
diff --git a/library/Director/Web/Form/Element/InstanceSummary.php b/library/Director/Web/Form/Element/InstanceSummary.php
new file mode 100644
index 0000000..722ad26
--- /dev/null
+++ b/library/Director/Web/Form/Element/InstanceSummary.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Html;
+
+/**
+ * Used by the
+ */
+class InstanceSummary extends FormElement
+{
+ public $helper = 'formSimpleNote';
+
+ /**
+ * Always ignore this element
+ * @codingStandardsIgnoreStart
+ *
+ * @var boolean
+ */
+ protected $_ignore = true;
+ // @codingStandardsIgnoreEnd
+
+ private $instances;
+
+ /** @var array will be set via options */
+ protected $linkParams;
+
+ public function setValue($value)
+ {
+ $this->instances = $value;
+ return $this;
+ }
+
+ public function getValue()
+ {
+ return Html::tag('span', [
+ Html::tag('italic', 'empty'),
+ ' ',
+ Link::create('Manage Instances', 'director/data/dictionary', $this->linkParams, [
+ 'data-base-target' => '_next',
+ 'class' => 'icon-forward'
+ ])
+ ]);
+ }
+
+ public function isValid($value, $context = null)
+ {
+ return true;
+ }
+}
diff --git a/library/Director/Web/Form/Element/OptionalYesNo.php b/library/Director/Web/Form/Element/OptionalYesNo.php
new file mode 100644
index 0000000..7ef6d7f
--- /dev/null
+++ b/library/Director/Web/Form/Element/OptionalYesNo.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+/**
+ * Input control for booleans, gives y/n
+ */
+class OptionalYesNo extends Boolean
+{
+ public function getValue()
+ {
+ $value = $this->getUnfilteredValue();
+
+ if ($value === 'y' || $value === true) {
+ return 'y';
+ } elseif ($value === 'n' || $value === false) {
+ return 'n';
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Web/Form/Element/SimpleNote.php b/library/Director/Web/Form/Element/SimpleNote.php
new file mode 100644
index 0000000..3097e11
--- /dev/null
+++ b/library/Director/Web/Form/Element/SimpleNote.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Icinga\Module\Director\PlainObjectRenderer;
+use ipl\Html\ValidHtml;
+
+class SimpleNote extends FormElement
+{
+ public $helper = 'formSimpleNote';
+
+ /**
+ * Always ignore this element
+ * @codingStandardsIgnoreStart
+ *
+ * @var boolean
+ */
+ protected $_ignore = true;
+ // @codingStandardsIgnoreEnd
+
+ public function isValid($value, $context = null)
+ {
+ return true;
+ }
+
+ public function setValue($value)
+ {
+ if (is_object($value) && ! $value instanceof ValidHtml) {
+ $value = 'Unexpected object: ' . PlainObjectRenderer::render($value);
+ }
+
+ return parent::setValue($value);
+ }
+}
diff --git a/library/Director/Web/Form/Element/StoredPassword.php b/library/Director/Web/Form/Element/StoredPassword.php
new file mode 100644
index 0000000..fa0545b
--- /dev/null
+++ b/library/Director/Web/Form/Element/StoredPassword.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Text as ZfText;
+
+/**
+ * StoredPassword
+ *
+ * This is a special form field and it might look a little bit weird at first
+ * sight. It's main use-case are stored cleartext passwords a user should be
+ * allowed to change.
+ *
+ * While this might sound simple, it's quite tricky if you try to fulfill the
+ * following requirements:
+ *
+ * - the current password should not be rendered to the HTML page (unless the
+ * user decides to change it)
+ * - it must be possible to visually distinct whether a password has been set
+ * - it should be impossible to "see" the length of the stored password
+ * - a changed password must be persisted
+ * - forms might be subject to multiple submissions in case other fields fail.
+ * If the user changed the password during the first submission attempt, the
+ * new string should not be lost.
+ * - all this must happen within the bounds of ZF1 form elements and related
+ * view helpers. This means that there is no related context available - and
+ * we do not know whether the form has been submitted and whether the current
+ * values have been populated from DB
+ *
+ * @package Icinga\Module\Director\Web\Form\Element
+ */
+class StoredPassword extends ZfText
+{
+ const UNCHANGED = '__UNCHANGED_VALUE__';
+
+ public $helper = 'formStoredPassword';
+
+ public function setValue($value)
+ {
+ if (\is_array($value) && isset($value['_value'], $value['_sent'])
+ && $value['_sent'] === 'y'
+ ) {
+ $value = $sentValue = $value['_value'];
+ if ($sentValue !== self::UNCHANGED) {
+ $this->setAttrib('sentValue', $sentValue);
+ }
+ } else {
+ $sentValue = null;
+ }
+
+ if ($value === self::UNCHANGED) {
+ return $this;
+ } else {
+ // Workaround for issue with modified DataTypes. This is Director-specific
+ if (\is_array($value)) {
+ $value = \json_encode($value);
+ }
+
+ return parent::setValue((string) $value);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/Element/Text.php b/library/Director/Web/Form/Element/Text.php
new file mode 100644
index 0000000..eeb36f1
--- /dev/null
+++ b/library/Director/Web/Form/Element/Text.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Text as ZfText;
+
+class Text extends ZfText
+{
+ public function setValue($value)
+ {
+ if (\is_array($value)) {
+ $value = \json_encode($value);
+ }
+ return parent::setValue((string) $value);
+ }
+}
diff --git a/library/Director/Web/Form/Element/YesNo.php b/library/Director/Web/Form/Element/YesNo.php
new file mode 100644
index 0000000..3e8aaa7
--- /dev/null
+++ b/library/Director/Web/Form/Element/YesNo.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+/**
+ * Input control for booleans, gives y/n
+ */
+class YesNo extends OptionalYesNo
+{
+ public $options = array(
+ 'y' => 'Yes',
+ 'n' => 'No',
+ );
+}
diff --git a/library/Director/Web/Form/Filter/QueryColumnsFromSql.php b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php
new file mode 100644
index 0000000..6f6d475
--- /dev/null
+++ b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Filter;
+
+use Exception;
+use Icinga\Data\Db\DbConnection;
+use Icinga\Module\Director\Forms\ImportSourceForm;
+use Zend_Filter_Interface;
+
+class QueryColumnsFromSql implements Zend_Filter_Interface
+{
+ /** @var ImportSourceForm */
+ private $form;
+
+ public function __construct(ImportSourceForm $form)
+ {
+ $this->form = $form;
+ }
+
+ public function filter($value)
+ {
+ $form = $this->form;
+ if (empty($value) || $form->hasChangedSetting('query')) {
+ try {
+ return implode(
+ ', ',
+ $this->getQueryColumns($form->getSentOrObjectSetting('query'))
+ );
+ } catch (Exception $e) {
+ $this->form->addUniqueException($e);
+ return '';
+ }
+ } else {
+ return $value;
+ }
+ }
+
+ protected function getQueryColumns($query)
+ {
+ $resourceName = $this->form->getSentOrObjectSetting('resource');
+ if (! $resourceName) {
+ return [];
+ }
+ $db = DbConnection::fromResourceName($resourceName)->getDbAdapter();
+
+ return array_keys((array) current($db->fetchAll($query)));
+ }
+}
diff --git a/library/Director/Web/Form/FormLoader.php b/library/Director/Web/Form/FormLoader.php
new file mode 100644
index 0000000..ea82857
--- /dev/null
+++ b/library/Director/Web/Form/FormLoader.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use Icinga\Exception\ProgrammingError;
+use RuntimeException;
+
+class FormLoader
+{
+ public static function load($name, Module $module = null)
+ {
+ if ($module === null) {
+ try {
+ $basedir = Icinga::app()->getApplicationDir('forms');
+ } catch (ProgrammingError $e) {
+ throw new RuntimeException($e->getMessage(), 0, $e);
+ }
+ $ns = '\\Icinga\\Web\\Forms\\';
+ } else {
+ $basedir = $module->getFormDir();
+ $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Forms\\';
+ }
+ if (preg_match('~^[a-z0-9/]+$~i', $name)) {
+ $parts = preg_split('~/~', $name);
+ $class = ucfirst(array_pop($parts)) . 'Form';
+ $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class);
+ if (file_exists($file)) {
+ require_once($file);
+ $class = $ns . $class;
+ $options = array();
+ if ($module !== null) {
+ $options['icingaModule'] = $module;
+ }
+
+ return new $class($options);
+ }
+ }
+
+ throw new RuntimeException(sprintf('Cannot load %s (%s), no such form', $name, $file));
+ }
+}
diff --git a/library/Director/Web/Form/IcingaObjectFieldLoader.php b/library/Director/Web/Form/IcingaObjectFieldLoader.php
new file mode 100644
index 0000000..c900edf
--- /dev/null
+++ b/library/Director/Web/Form/IcingaObjectFieldLoader.php
@@ -0,0 +1,628 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Exception;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Hook\HostFieldHook;
+use Icinga\Module\Director\Hook\ServiceFieldHook;
+use Icinga\Module\Director\Objects\DirectorDatafieldCategory;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\DirectorDatafield;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\ObjectApplyMatches;
+use Icinga\Web\Hook;
+use stdClass;
+use Zend_Db_Select as ZfSelect;
+use Zend_Form_Element as ZfElement;
+
+class IcingaObjectFieldLoader
+{
+ protected $form;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var \Icinga\Module\Director\Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var DirectorDatafield[] */
+ protected $fields;
+
+ protected $elements;
+
+ protected $forceNull = array();
+
+ /** @var array Map element names to variable names 'elName' => 'varName' */
+ protected $nameMap = array();
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ $this->connection = $object->getConnection();
+ $this->db = $this->connection->getDbAdapter();
+ }
+
+ public function addFieldsToForm(DirectorObjectForm $form)
+ {
+ if ($this->fields || $this->object->supportsFields()) {
+ $this->attachFieldsToForm($form);
+ }
+
+ return $this;
+ }
+
+ public function loadFieldsForMultipleObjects($objects)
+ {
+ $fields = array();
+ foreach ($objects as $object) {
+ foreach ($this->prepareObjectFields($object) as $varname => $field) {
+ $varname = $field->get('varname');
+ if (array_key_exists($varname, $fields)) {
+ if ($field->get('datatype') !== $fields[$varname]->datatype) {
+ unset($fields[$varname]);
+ }
+
+ continue;
+ }
+
+ $fields[$varname] = $field;
+ }
+ }
+
+ $this->fields = $fields;
+
+ return $this;
+ }
+
+ /**
+ * Set a list of values
+ *
+ * Works in a fail-safe way, when a field does not exist the value will be
+ * silently ignored
+ *
+ * @param array $values key/value pairs with variable names and their value
+ * @param string $prefix An optional prefix that would be stripped from keys
+ *
+ * @return IcingaObjectFieldLoader
+ *
+ * @throws IcingaException
+ */
+ public function setValues($values, $prefix = null)
+ {
+ if (! $this->object->supportsCustomVars()) {
+ return $this;
+ }
+
+ if ($prefix === null) {
+ $len = null;
+ } else {
+ $len = strlen($prefix);
+ }
+ $vars = $this->object->vars();
+
+ foreach ($values as $key => $value) {
+ if ($len !== null) {
+ if (substr($key, 0, $len) === $prefix) {
+ $key = substr($key, $len);
+ } else {
+ continue;
+ }
+ }
+
+ $varName = $this->getElementVarName($prefix . $key);
+ if ($varName === null) {
+ // throw new IcingaException(
+ // 'Cannot set variable value for "%s", got no such element',
+ // $key
+ // );
+
+ // Silently ignore additional fields. One might have switched
+ // template or command
+ continue;
+ }
+
+ $el = $this->getElement($varName);
+ if ($el === null) {
+ // throw new IcingaException('No such element %s', $key);
+ // Same here.
+ continue;
+ }
+
+ $el->setValue($value);
+ $value = $el->getValue();
+ if ($value === '' || $value === array()) {
+ $value = null;
+ }
+
+ $vars->set($varName, $value);
+ }
+
+ // Hint: this does currently not happen, as removeFilteredFields did not
+ // take place yet. This has been added to be on the safe side when
+ // cleaning things up one future day
+ foreach ($this->forceNull as $key) {
+ $vars->set($key, null);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the fields for our object
+ *
+ * @return DirectorDatafield[]
+ */
+ public function getFields()
+ {
+ if ($this->fields === null) {
+ $this->fields = $this->prepareObjectFields($this->object);
+ }
+
+ return $this->fields;
+ }
+
+ /**
+ * Get the form elements for our fields
+ *
+ * @param DirectorObjectForm $form Optional
+ *
+ * @return ZfElement[]
+ */
+ public function getElements(DirectorObjectForm $form = null)
+ {
+ if ($this->elements === null) {
+ $this->elements = $this->createElements($form);
+ $this->setValuesFromObject($this->object);
+ }
+
+ return $this->elements;
+ }
+
+ /**
+ * Prepare the form elements for our fields
+ *
+ * @param DirectorObjectForm $form Optional
+ *
+ * @return self
+ */
+ public function prepareElements(DirectorObjectForm $form = null)
+ {
+ if ($this->object->supportsFields()) {
+ $this->getElements($form);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Attach our form fields to the given form
+ *
+ * This will also create a 'Custom properties' display group
+ *
+ * @param DirectorObjectForm $form
+ */
+ protected function attachFieldsToForm(DirectorObjectForm $form)
+ {
+ if ($this->fields === null) {
+ return;
+ }
+ $elements = $this->removeFilteredFields($this->getElements($form));
+
+ foreach ($elements as $element) {
+ $form->addElement($element);
+ }
+
+ $this->attachGroupElements($elements, $form);
+ }
+
+ /**
+ * @param ZfElement[] $elements
+ * @param DirectorObjectForm $form
+ */
+ protected function attachGroupElements(array $elements, DirectorObjectForm $form)
+ {
+ $categories = [];
+ $categoriesFetchedById = [];
+ foreach ($this->fields as $key => $field) {
+ if ($id = $field->get('category_id')) {
+ if (isset($categoriesFetchedById[$id])) {
+ $category = $categoriesFetchedById[$id];
+ } else {
+ $category = DirectorDatafieldCategory::loadWithAutoIncId($id, $form->getDb());
+ $categoriesFetchedById[$id] = $category;
+ }
+ } elseif ($field->hasCategory()) {
+ $category = $field->getCategory();
+ } else {
+ continue;
+ }
+ $categories[$key] = $category;
+ }
+ $prioIdx = \array_flip(\array_keys($categories));
+
+ foreach ($elements as $key => $element) {
+ if (isset($categories[$key])) {
+ $category = $categories[$key];
+ $form->addElementsToGroup(
+ [$element],
+ 'custom_fields:' . $category->get('category_name'),
+ DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELD_CATEGORIES + $prioIdx[$key],
+ $category->get('category_name')
+ );
+ } else {
+ $form->addElementsToGroup(
+ [$element],
+ 'custom_fields',
+ DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELDS,
+ $form->translate('Custom properties')
+ );
+ }
+ }
+ }
+
+ /**
+ * @param ZfElement[] $elements
+ * @return ZfElement[]
+ */
+ protected function removeFilteredFields(array $elements)
+ {
+ $filters = array();
+ foreach ($this->fields as $key => $field) {
+ if ($filter = $field->var_filter) {
+ $filters[$key] = Filter::fromQueryString($filter);
+ }
+ }
+
+ $kill = array();
+ $columns = array();
+ $object = $this->object;
+ if ($object instanceof IcingaHost) {
+ $prefix = 'host.vars.';
+ } elseif ($object instanceof IcingaService) {
+ $prefix = 'service.vars.';
+ } else {
+ return $elements;
+ }
+
+ $object->invalidateResolveCache();
+ $vars = $object::fromPlainObject(
+ $object->toPlainObject(true),
+ $object->getConnection()
+ )->getVars();
+
+ $prefixedVars = (object) array();
+ foreach ($vars as $k => $v) {
+ $prefixedVars->{$prefix . $k} = $v;
+ }
+
+ foreach ($filters as $key => $filter) {
+ ObjectApplyMatches::fixFilterColumns($filter);
+ /** @var $filter FilterChain|FilterExpression */
+ foreach ($filter->listFilteredColumns() as $column) {
+ $column = substr($column, strlen($prefix));
+ $columns[$column] = $column;
+ }
+ if (! $filter->matches($prefixedVars)) {
+ $kill[] = $key;
+ }
+ }
+
+ $vars = $object->vars();
+ foreach ($kill as $key) {
+ unset($elements[$key]);
+ $this->forceNull[$key] = $key;
+ // Hint: this should happen later on, currently execution order is
+ // a little bit weird
+ $vars->set($key, null);
+ }
+
+ foreach ($columns as $col) {
+ if (array_key_exists($col, $elements)) {
+ $el = $elements[$col];
+ $existingClass = $el->getAttrib('class');
+ if ($existingClass !== null && strlen($existingClass)) {
+ $el->setAttrib('class', $existingClass . ' autosubmit');
+ } else {
+ $el->setAttrib('class', 'autosubmit');
+ }
+ }
+ }
+
+ return $elements;
+ }
+
+ protected function getElementVarName($name)
+ {
+ if (array_key_exists($name, $this->nameMap)) {
+ return $this->nameMap[$name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the form element for a specific field by it's variable name
+ *
+ * @param string $name
+ * @return null|ZfElement
+ */
+ protected function getElement($name)
+ {
+ $elements = $this->getElements();
+ if (array_key_exists($name, $elements)) {
+ return $this->elements[$name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the form elements based on the given form
+ *
+ * @param DirectorObjectForm $form
+ *
+ * @return ZfElement[]
+ */
+ protected function createElements(DirectorObjectForm $form)
+ {
+ $elements = array();
+
+ foreach ($this->getFields() as $name => $field) {
+ $el = $field->getFormElement($form);
+ $elName = $el->getName();
+ if (array_key_exists($elName, $this->nameMap)) {
+ $form->addErrorMessage(sprintf(
+ 'Form element name collision, "%s" resolves to "%s", but this is also used for "%s"',
+ $name,
+ $elName,
+ $this->nameMap[$elName]
+ ));
+ }
+ $this->nameMap[$elName] = $name;
+ $elements[$name] = $el;
+ }
+
+ return $elements;
+ }
+
+ /**
+ * @param IcingaObject $object
+ */
+ protected function setValuesFromObject(IcingaObject $object)
+ {
+ foreach ($object->getVars() as $k => $v) {
+ if ($v !== null && $el = $this->getElement($k)) {
+ $el->setValue($v);
+ }
+ }
+ }
+
+ protected function mergeFields($listOfFields)
+ {
+ // TODO: Merge field for different object, mostly sets
+ }
+
+ /**
+ * Create the fields for our object
+ *
+ * @param IcingaObject $object
+ * @return DirectorDatafield[]
+ */
+ protected function prepareObjectFields($object)
+ {
+ $fields = $this->loadResolvedFieldsForObject($object);
+ if ($object->hasRelation('check_command')) {
+ try {
+ /** @var IcingaCommand $command */
+ $command = $object->getResolvedRelated('check_command');
+ } catch (Exception $e) {
+ // Ignore failures
+ $command = null;
+ }
+
+ if ($command) {
+ $cmdLoader = new static($command);
+ $cmdFields = $cmdLoader->getFields();
+ foreach ($cmdFields as $varname => $field) {
+ if (! array_key_exists($varname, $fields)) {
+ $fields[$varname] = $field;
+ }
+ }
+ }
+
+ // TODO -> filters!
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Create the fields for our object
+ *
+ * Follows the inheritance logic, resolves all fields and keeps the most
+ * specific ones. Returns a list of fields indexed by variable name
+ *
+ * @param IcingaObject $object
+ *
+ * @return DirectorDatafield[]
+ */
+ protected function loadResolvedFieldsForObject(IcingaObject $object)
+ {
+ $result = $this->loadDataFieldsForObject(
+ $object
+ );
+
+ $fields = array();
+ foreach ($result as $objectId => $varFields) {
+ foreach ($varFields as $var => $field) {
+ $fields[$var] = $field;
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @param IcingaObject[] $objectList List of objects
+ *
+ * @return array
+ */
+ protected function getIdsForObjectList($objectList)
+ {
+ $ids = [];
+ foreach ($objectList as $object) {
+ if ($object->hasBeenLoadedFromDb()) {
+ $ids[] = $object->get('id');
+ }
+ }
+
+ return $ids;
+ }
+
+ public function fetchFieldDetailsForObject(IcingaObject $object)
+ {
+ $ids = $object->listAncestorIds();
+ if ($id = $object->getProperty('id')) {
+ $ids[] = $id;
+ }
+ return $this->fetchFieldDetailsForIds($ids);
+ }
+
+ /***
+ * @param $objectIds
+ *
+ * @return \stdClass[]
+ */
+ protected function fetchFieldDetailsForIds($objectIds)
+ {
+ if (empty($objectIds)) {
+ return [];
+ }
+
+ $query = $this->prepareSelectForIds($objectIds);
+ return $this->db->fetchAll($query);
+ }
+
+ /**
+ * @param array $ids
+ *
+ * @return ZfSelect
+ */
+ protected function prepareSelectForIds(array $ids)
+ {
+ $object = $this->object;
+
+ $idColumn = 'f.' . $object->getShortTableName() . '_id';
+
+ $query = $this->db->select()->from(
+ array('df' => 'director_datafield'),
+ array(
+ 'object_id' => $idColumn,
+ 'icinga_type' => "('" . $object->getShortTableName() . "')",
+ 'var_filter' => 'f.var_filter',
+ 'is_required' => 'f.is_required',
+ 'id' => 'df.id',
+ 'category_id' => 'df.category_id',
+ 'varname' => 'df.varname',
+ 'caption' => 'df.caption',
+ 'description' => 'df.description',
+ 'datatype' => 'df.datatype',
+ 'format' => 'df.format',
+ )
+ )->join(
+ array('f' => $object->getTableName() . '_field'),
+ 'df.id = f.datafield_id',
+ array()
+ )->where($idColumn . ' IN (?)', $ids)
+ ->order('CASE WHEN var_filter IS NULL THEN 0 ELSE 1 END ASC')
+ ->order('df.caption ASC');
+
+ return $query;
+ }
+
+ /**
+ * Fetches fields for a given object
+ *
+ * Gives a list indexed by object id, with each entry being a list of that
+ * objects DirectorDatafield instances indexed by variable name
+ *
+ * @param IcingaObject $object
+ *
+ * @return array
+ */
+ public function loadDataFieldsForObject(IcingaObject $object)
+ {
+ $res = $this->fetchFieldDetailsForObject($object);
+
+ $result = [];
+ foreach ($res as $r) {
+ $id = $r->object_id;
+ unset($r->object_id);
+ if (! array_key_exists($id, $result)) {
+ $result[$id] = new stdClass;
+ }
+
+ $result[$id]->{$r->varname} = DirectorDatafield::fromDbRow(
+ $r,
+ $this->connection
+ );
+ }
+
+ foreach ($this->loadHookedDataFieldForObject($object) as $id => $fields) {
+ if (array_key_exists($id, $result)) {
+ foreach ($fields as $varName => $field) {
+ $result[$id]->$varName = $field;
+ }
+ } else {
+ $result[$id] = $fields;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return array
+ */
+ protected function loadHookedDataFieldForObject(IcingaObject $object)
+ {
+ $fields = [];
+ if ($object instanceof IcingaHost || $object instanceof IcingaService) {
+ $fields = $this->addHookedFields($object);
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return mixed
+ */
+ protected function addHookedFields(IcingaObject $object)
+ {
+ $fields = [];
+ /** @var HostFieldHook|ServiceFieldHook $hook */
+ $type = ucfirst($object->getShortTableName());
+ foreach (Hook::all("Director\\${type}Field") as $hook) {
+ if ($hook->wants($object)) {
+ $id = $object->get('id');
+ $spec = $hook->getFieldSpec($object);
+ if (!array_key_exists($id, $fields)) {
+ $fields[$id] = new stdClass();
+ }
+ $fields[$id]->{$spec->getVarName()} = $spec->toDataField($object);
+ }
+ }
+ return $fields;
+ }
+}
diff --git a/library/Director/Web/Form/IconHelper.php b/library/Director/Web/Form/IconHelper.php
new file mode 100644
index 0000000..3add09b
--- /dev/null
+++ b/library/Director/Web/Form/IconHelper.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Icon helper class
+ *
+ * Should help to reduce redundant icon-lookup code. Currently with hardcoded
+ * icons only, could easily provide support for all of them as follows:
+ *
+ * $confFile = Icinga::app()
+ * ->getApplicationDir('fonts/fontello-ifont/config.json');
+ *
+ * $font = json_decode(file_get_contents($confFile));
+ * // 'icon-' is to be found in $font->css_prefix_text
+ * foreach ($font->glyphs as $icon) {
+ * // $icon->css (= 'help') -> 0x . dechex($icon->code)
+ * }
+ */
+class IconHelper
+{
+ private $icons = array(
+ 'minus' => 'e806',
+ 'trash' => 'e846',
+ 'plus' => 'e805',
+ 'cancel' => 'e804',
+ 'help' => 'e85b',
+ 'angle-double-right' => 'e87b',
+ 'up-big' => 'e825',
+ 'down-big' => 'e828',
+ 'down-open' => 'e821',
+ );
+
+ private $mappedUtf8Icons;
+
+ private $reversedUtf8Icons;
+
+ private static $instance;
+
+ public function __construct()
+ {
+ $this->prepareIconMappings();
+ }
+
+ public static function instance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new static;
+ }
+
+ return self::$instance;
+ }
+
+ public function characterIconName($character)
+ {
+ if (array_key_exists($character, $this->reversedUtf8Icons)) {
+ return $this->reversedUtf8Icons[$character];
+ } else {
+ throw new ProgrammingError('There is no mapping for the given character');
+ }
+ }
+
+ protected function hexToCharacter($hex)
+ {
+ return json_decode('"\u' . $hex . '"');
+ }
+
+ public function iconCharacter($name)
+ {
+ if (array_key_exists($name, $this->mappedUtf8Icons)) {
+ return $this->mappedUtf8Icons[$name];
+ } else {
+ return $this->mappedUtf8Icons['help'];
+ }
+ }
+
+ protected function prepareIconMappings()
+ {
+ $this->mappedUtf8Icons = array();
+ $this->reversedUtf8Icons = array();
+ foreach ($this->icons as $name => $hex) {
+ $character = $this->hexToCharacter($hex);
+ $this->mappedUtf8Icons[$name] = $character;
+ $this->reversedUtf8Icons[$character] = $name;
+ }
+ }
+}
diff --git a/library/Director/Web/Form/IplElement/ExtensibleSetElement.php b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php
new file mode 100644
index 0000000..a4dbb20
--- /dev/null
+++ b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php
@@ -0,0 +1,570 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\IplElement;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\IcingaConfig\ExtensibleSet as Set;
+use Icinga\Module\Director\Web\Form\IconHelper;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class ExtensibleSetElement extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'ul';
+
+ /** @var Set */
+ protected $set;
+
+ private $id;
+
+ private $name;
+
+ private $value;
+
+ private $description;
+
+ private $multiOptions;
+
+ private $validOptions;
+
+ private $chosenOptionCount = 0;
+
+ private $suggestionContext;
+
+ private $sorted = false;
+
+ private $disabled = false;
+
+ private $remainingAttribs;
+
+ private $hideOptions = [];
+
+ private $inherited;
+
+ private $inheritedFrom;
+
+ protected $defaultAttributes = [
+ 'class' => 'extensible-set'
+ ];
+
+ protected function __construct($name)
+ {
+ $this->name = $this->id = $name;
+ }
+
+ public function hideOptions($options)
+ {
+ $this->hideOptions = array_merge($this->hideOptions, $options);
+ return $this;
+ }
+
+ private function setMultiOptions($options)
+ {
+ $this->multiOptions = $options;
+ $this->validOptions = $this->flattenOptions($options);
+ }
+
+ protected function isValidOption($option)
+ {
+ if ($this->validOptions === null) {
+ if ($this->suggestionContext === null) {
+ return true;
+ } else {
+ // TODO: ask suggestionContext, if any
+ return true;
+ }
+ } else {
+ return in_array($option, $this->validOptions);
+ }
+ }
+
+ private function disable($disable = true)
+ {
+ $this->disabled = (bool) $disable;
+ }
+
+ private function isDisabled()
+ {
+ return $this->disabled;
+ }
+
+ private function isSorted()
+ {
+ return $this->sorted;
+ }
+
+ public function setValue($value)
+ {
+ if ($value instanceof Set) {
+ $value = $value->toPlainObject();
+ }
+
+ if (is_array($value)) {
+ $value = array_filter($value, 'strlen');
+ }
+
+ if (null !== $value && ! is_array($value)) {
+ throw new ProgrammingError(
+ 'Got unexpected value, no array: %s',
+ var_export($value, 1)
+ );
+ }
+
+ $this->value = $value;
+ return $this;
+ }
+
+ protected function extractZfInfo(&$attribs = null)
+ {
+ if ($attribs === null) {
+ return;
+ }
+
+ foreach (['id', 'name', 'descriptions'] as $key) {
+ if (array_key_exists($key, $attribs)) {
+ $this->$key = $attribs[$key];
+ unset($attribs[$key]);
+ }
+ }
+ if (array_key_exists('disable', $attribs)) {
+ $this->disable($attribs['disable']);
+ unset($attribs['disable']);
+ }
+ if (array_key_exists('value', $attribs)) {
+ $this->setValue($attribs['value']);
+ unset($attribs['value']);
+ }
+ if (array_key_exists('inherited', $attribs)) {
+ $this->inherited = $attribs['inherited'];
+ unset($attribs['inherited']);
+ }
+ if (array_key_exists('inheritedFrom', $attribs)) {
+ $this->inheritedFrom = $attribs['inheritedFrom'];
+ unset($attribs['inheritedFrom']);
+ }
+
+ if (array_key_exists('multiOptions', $attribs)) {
+ $this->setMultiOptions($attribs['multiOptions']);
+ unset($attribs['multiOptions']);
+ }
+
+ if (array_key_exists('hideOptions', $attribs)) {
+ $this->hideOptions($attribs['hideOptions']);
+ unset($attribs['hideOptions']);
+ }
+
+ if (array_key_exists('sorted', $attribs)) {
+ $this->sorted = (bool) $attribs['sorted'];
+ unset($attribs['sorted']);
+ }
+
+ if (array_key_exists('description', $attribs)) {
+ $this->description = $attribs['description'];
+ unset($attribs['description']);
+ }
+
+ if (array_key_exists('suggest', $attribs)) {
+ $this->suggestionContext = $attribs['suggest'];
+ unset($attribs['suggest']);
+ }
+
+ if (! empty($attribs)) {
+ $this->remainingAttribs = $attribs;
+ }
+ }
+
+ /**
+ * Generates an 'extensible set' element.
+ *
+ * @codingStandardsIgnoreEnd
+ *
+ * @param string|array $name If a string, the element name. If an
+ * array, all other parameters are ignored, and the array elements
+ * are used in place of added parameters.
+ *
+ * @param mixed $value The element value.
+ *
+ * @param array $attribs Attributes for the element tag.
+ *
+ * @return string The element XHTML.
+ */
+ public static function fromZfDingens($name, $value = null, $attribs = null)
+ {
+ $el = new static($name);
+ $el->extractZfInfo($attribs);
+ $el->setValue($value);
+ return $el->render();
+ }
+
+ protected function assemble()
+ {
+ $this->addChosenOptions();
+ $this->addAddMore();
+
+ if ($this->isSorted()) {
+ $this->getAttributes()->add('class', 'sortable');
+ }
+ if (null !== $this->description) {
+ $this->addDescription($this->description);
+ }
+ }
+
+ private function eventuallyAddAutosuggestion(BaseHtmlElement $element)
+ {
+ if ($this->suggestionContext !== null) {
+ $attrs = $element->getAttributes();
+ $attrs->add('class', 'director-suggest');
+ $attrs->set([
+ 'data-suggestion-context' => $this->suggestionContext,
+ ]);
+ }
+
+ return $element;
+ }
+
+ private function hasAvailableMultiOptions()
+ {
+ return count($this->multiOptions) > 1 || strlen(key($this->multiOptions));
+ }
+
+ private function addAddMore()
+ {
+ $cnt = $this->chosenOptionCount;
+
+ if ($this->multiOptions) {
+ if (! $this->hasAvailableMultiOptions()) {
+ return;
+ }
+ $field = Html::tag('select', ['class' => 'autosubmit']);
+ $more = $this->inherited === null
+ ? $this->translate('- add more -')
+ : $this->getInheritedInfo();
+ $field->add(Html::tag('option', [
+ 'value' => '',
+ 'tabindex' => '-1'
+ ], $more));
+
+ foreach ($this->multiOptions as $key => $label) {
+ if ($key === null) {
+ $key = '';
+ }
+ if (is_array($label)) {
+ $optGroup = Html::tag('optgroup', ['label' => $key]);
+ foreach ($label as $grpKey => $grpLabel) {
+ $optGroup->add(
+ Html::tag('option', ['value' => $grpKey], $grpLabel)
+ );
+ }
+ $field->add($optGroup);
+ } else {
+ $option = Html::tag('option', ['value' => $key], $label);
+ $field->add($option);
+ }
+ }
+ } else {
+ $field = Html::tag('input', [
+ 'type' => 'text',
+ 'placeholder' => $this->inherited === null
+ ? $this->translate('Add a new one...')
+ : $this->getInheritedInfo(),
+ ]);
+ }
+ $field->addAttributes([
+ 'id' => $this->id . $this->suffix($cnt),
+ 'name' => $this->name . '[]',
+ ]);
+ $this->eventuallyAddAutosuggestion(
+ $this->addRemainingAttributes(
+ $this->eventuallyDisable($field)
+ )
+ );
+ if ($cnt !== 0) { // TODO: was === 0?!
+ $field->getAttributes()->add('class', 'extend-set');
+ }
+
+ if ($this->suggestionContext === null) {
+ $this->add(Html::tag('li', null, [
+ $this->createAddNewButton(),
+ $field
+ ]));
+ } else {
+ $this->add(Html::tag('li', null, [
+ $this->newInlineButtons(
+ $this->renderDropDownButton()
+ ),
+ $field
+ ]));
+ }
+ }
+
+ private function getInheritedInfo()
+ {
+ if ($this->inheritedFrom === null) {
+ return \sprintf(
+ $this->translate('%s (inherited)'),
+ $this->stringifyInheritedValue()
+ );
+ } else {
+ return \sprintf(
+ $this->translate('%s (inherited from %s)'),
+ $this->stringifyInheritedValue(),
+ $this->inheritedFrom
+ );
+ }
+ }
+
+ private function stringifyInheritedValue()
+ {
+ if (\is_array($this->inherited)) {
+ return \implode(', ', $this->inherited);
+ } else {
+ return \sprintf(
+ $this->translate('%s (not an Array!)'),
+ \var_export($this->inherited, 1)
+ );
+ }
+ }
+
+ private function createAddNewButton()
+ {
+ return $this->newInlineButtons(
+ $this->eventuallyDisable($this->renderAddButton())
+ );
+ }
+
+ private function addChosenOptions()
+ {
+ if (null === $this->value) {
+ return;
+ }
+ $total = count($this->value);
+
+ foreach ($this->value as $val) {
+ if (in_array($val, $this->hideOptions)) {
+ continue;
+ }
+
+ if ($this->multiOptions !== null) {
+ if ($this->isValidOption($val)) {
+ $this->multiOptions = $this->removeOption(
+ $this->multiOptions,
+ $val
+ );
+ // TODO:
+ // $this->removeOption($val);
+ }
+ }
+
+ $text = Html::tag('input', [
+ 'type' => 'text',
+ 'name' => $this->name . '[]',
+ 'id' => $this->id . $this->suffix($this->chosenOptionCount),
+ 'value' => $val
+ ]);
+ $text->getAttributes()->set([
+ 'autocomplete' => 'off',
+ 'autocorrect' => 'off',
+ 'autocapitalize' => 'off',
+ 'spellcheck' => 'false',
+ ]);
+
+ $this->addRemainingAttributes($this->eventuallyDisable($text));
+ $this->add(Html::tag('li', null, [
+ $this->getOptionButtons($this->chosenOptionCount, $total),
+ $text
+ ]));
+ $this->chosenOptionCount++;
+ }
+ }
+
+ private function addRemainingAttributes(BaseHtmlElement $element)
+ {
+ if ($this->remainingAttribs !== null) {
+ $element->getAttributes()->add($this->remainingAttribs);
+ }
+
+ return $element;
+ }
+
+ private function eventuallyDisable(BaseHtmlElement $element)
+ {
+ if ($this->isDisabled()) {
+ $this->disableElement($element);
+ }
+
+ return $element;
+ }
+
+ private function disableElement(BaseHtmlElement $element)
+ {
+ $element->getAttributes()->set('disabled', 'disabled');
+ return $element;
+ }
+
+ private function disableIf(BaseHtmlElement $element, $condition)
+ {
+ if ($condition) {
+ $this->disableElement($element);
+ }
+
+ return $element;
+ }
+
+ private function getOptionButtons($cnt, $total)
+ {
+ if ($this->isDisabled()) {
+ return [];
+ }
+ $first = $cnt === 0;
+ $last = $cnt === $total - 1;
+ $name = $this->name;
+ $buttons = $this->newInlineButtons();
+ if ($this->isSorted()) {
+ $buttons->add([
+ $this->disableIf($this->renderDownButton($name, $cnt), $last),
+ $this->disableIf($this->renderUpButton($name, $cnt), $first)
+ ]);
+ }
+
+ $buttons->add($this->renderDeleteButton($name, $cnt));
+
+ return $buttons;
+ }
+
+ protected function newInlineButtons($content = null)
+ {
+ return Html::tag('span', ['class' => 'inline-buttons'], $content);
+ }
+
+ protected function addDescription($description)
+ {
+ $this->add(
+ Html::tag('p', ['class' => 'description'], $description)
+ );
+ }
+
+ private function flattenOptions($options)
+ {
+ $flat = array();
+
+ foreach ($options as $key => $option) {
+ if (is_array($option)) {
+ foreach ($option as $k => $o) {
+ $flat[] = $k;
+ }
+ } else {
+ $flat[] = $key;
+ }
+ }
+
+ return $flat;
+ }
+
+ private function removeOption($options, $option)
+ {
+ $unset = array();
+ foreach ($options as $key => & $value) {
+ if (is_array($value)) {
+ $value = $this->removeOption($value, $option);
+ if (empty($value)) {
+ $unset[] = $key;
+ }
+ } elseif ($key === $option) {
+ $unset[] = $key;
+ }
+ }
+
+ foreach ($unset as $key) {
+ unset($options[$key]);
+ }
+
+ return $options;
+ }
+
+ private function suffix($cnt)
+ {
+ if ($cnt === 0) {
+ return '';
+ } else {
+ return '_' . $cnt;
+ }
+ }
+
+ private function renderDropDownButton()
+ {
+ return $this->createRelatedAction(
+ 'drop-down',
+ $this->name,
+ $this->translate('Show available options'),
+ 'down-open'
+ );
+ }
+
+ private function renderAddButton()
+ {
+ return $this->createRelatedAction(
+ 'add',
+ // This would interfere with how PHP resolves _POST arrays. So we
+ // use a fake name for now, that way the button will be ignored and
+ // behave similar to an auto-submission
+ 'X_' . $this->name,
+ $this->translate('Add a new entry'),
+ 'plus'
+ );
+ }
+
+ private function renderDeleteButton($name, $cnt)
+ {
+ return $this->createRelatedAction(
+ 'remove',
+ $name . '_' . $cnt,
+ $this->translate('Remove this entry'),
+ 'cancel'
+ );
+ }
+
+ private function renderUpButton($name, $cnt)
+ {
+ return $this->createRelatedAction(
+ 'move-up',
+ $name . '_' . $cnt,
+ $this->translate('Move up'),
+ 'up-big'
+ );
+ }
+
+ private function renderDownButton($name, $cnt)
+ {
+ return $this->createRelatedAction(
+ 'move-down',
+ $name . '_' . $cnt,
+ $this->translate('Move down'),
+ 'down-big'
+ );
+ }
+
+ protected function makeActionName($name, $action)
+ {
+ return $name . '__' . str_replace('-', '_', strtoupper($action));
+ }
+
+ protected function createRelatedAction(
+ $action,
+ $name,
+ $title,
+ $icon
+ ) {
+ $input = Html::tag('input', [
+ 'type' => 'submit',
+ 'class' => ['related-action', 'action-' . $action],
+ 'name' => $this->makeActionName($name, $action),
+ 'value' => IconHelper::instance()->iconCharacter($icon),
+ 'title' => $title
+ ]);
+
+ return $input;
+ }
+}
diff --git a/library/Director/Web/Form/QuickBaseForm.php b/library/Director/Web/Form/QuickBaseForm.php
new file mode 100644
index 0000000..8d25ffb
--- /dev/null
+++ b/library/Director/Web/Form/QuickBaseForm.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use Zend_Form;
+
+abstract class QuickBaseForm extends Zend_Form implements ValidHtml
+{
+ /**
+ * The Icinga module this form belongs to. Usually only set if the
+ * form is initialized through the FormLoader
+ *
+ * @var Module
+ */
+ protected $icingaModule;
+
+ protected $icingaModuleName;
+
+ private $hintCount = 0;
+
+ public function __construct($options = null)
+ {
+ $this->callZfConstructor($this->handleOptions($options))
+ ->initializePrefixPaths();
+ }
+
+ protected function callZfConstructor($options = null)
+ {
+ parent::__construct($options);
+ return $this;
+ }
+
+ protected function initializePrefixPaths()
+ {
+ $this->addPrefixPathsForDirector();
+ if ($this->icingaModule && $this->icingaModuleName !== 'director') {
+ $this->addPrefixPathsForModule($this->icingaModule);
+ }
+ }
+
+ protected function addPrefixPathsForDirector()
+ {
+ $module = Icinga::app()
+ ->getModuleManager()
+ ->loadModule('director')
+ ->getModule('director');
+
+ $this->addPrefixPathsForModule($module);
+ }
+
+ public function addPrefixPathsForModule(Module $module)
+ {
+ $basedir = sprintf(
+ '%s/%s/Web/Form',
+ $module->getLibDir(),
+ ucfirst($module->getName())
+ );
+
+ $this->addPrefixPath(
+ __NAMESPACE__ . '\\Element\\',
+ $basedir . '/Element',
+ static::ELEMENT
+ );
+
+ return $this;
+ }
+
+ public function addHidden($name, $value = null)
+ {
+ $this->addElement('hidden', $name);
+ $el = $this->getElement($name);
+ $el->setDecorators(array('ViewHelper'));
+ if ($value !== null) {
+ $this->setDefault($name, $value);
+ $el->setValue($value);
+ }
+
+ return $this;
+ }
+
+ // TODO: Should be an element
+ public function addHtmlHint($html, $options = [])
+ {
+ return $this->addHtml(
+ Html::tag('div', ['class' => 'hint'], $html),
+ $options
+ );
+ }
+
+ public function addHtml($html, $options = [])
+ {
+ if ($html instanceof ValidHtml) {
+ $html = $html->render();
+ }
+
+ if (array_key_exists('name', $options)) {
+ $name = $options['name'];
+ unset($options['name']);
+ } else {
+ $name = '_HINT' . ++$this->hintCount;
+ }
+
+ $this->addElement('simpleNote', $name, $options);
+ $this->getElement($name)
+ ->setValue($html)
+ ->setIgnore(true)
+ ->setDecorators(array('ViewHelper'));
+
+ return $this;
+ }
+
+ public function optionalEnum($enum, $nullLabel = null)
+ {
+ if ($nullLabel === null) {
+ $nullLabel = $this->translate('- please choose -');
+ }
+
+ return array(null => $nullLabel) + $enum;
+ }
+
+ protected function handleOptions($options = null)
+ {
+ if ($options === null) {
+ return $options;
+ }
+
+ if (array_key_exists('icingaModule', $options)) {
+ /** @var Module icingaModule */
+ $this->icingaModule = $options['icingaModule'];
+ $this->icingaModuleName = $this->icingaModule->getName();
+ unset($options['icingaModule']);
+ }
+
+ return $options;
+ }
+
+ public function setIcingaModule(Module $module)
+ {
+ $this->icingaModule = $module;
+ return $this;
+ }
+
+ protected function loadForm($name, Module $module = null)
+ {
+ if ($module === null) {
+ $module = $this->icingaModule;
+ }
+
+ return FormLoader::load($name, $module);
+ }
+
+ protected function valueIsEmpty($value)
+ {
+ if ($value === null) {
+ return true;
+ }
+
+ if (is_array($value)) {
+ return empty($value);
+ }
+
+ return strlen($value) === 0;
+ }
+
+ public function translate($string)
+ {
+ if ($this->icingaModuleName === null) {
+ return t($string);
+ } else {
+ return mt($this->icingaModuleName, $string);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/QuickForm.php b/library/Director/Web/Form/QuickForm.php
new file mode 100644
index 0000000..91c8f00
--- /dev/null
+++ b/library/Director/Web/Form/QuickForm.php
@@ -0,0 +1,641 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\Notification;
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+use Icinga\Web\Url;
+use InvalidArgumentException;
+use Exception;
+use RuntimeException;
+
+/**
+ * QuickForm wants to be a base class for simple forms
+ */
+abstract class QuickForm extends QuickBaseForm
+{
+ const ID = '__FORM_NAME';
+
+ const CSRF = '__FORM_CSRF';
+
+ /**
+ * The name of this form
+ */
+ protected $formName;
+
+ /**
+ * Whether the form has been sent
+ */
+ protected $hasBeenSent;
+
+ /**
+ * Whether the form has been sent
+ */
+ protected $hasBeenSubmitted;
+
+ /**
+ * The submit caption, element - still tbd
+ */
+ // protected $submit;
+
+ /**
+ * Our request
+ */
+ protected $request;
+
+ protected $successUrl;
+
+ protected $successMessage;
+
+ protected $submitLabel;
+
+ protected $submitButtonName;
+
+ protected $deleteButtonName;
+
+ protected $fakeSubmitButtonName;
+
+ /**
+ * Whether form elements have already been created
+ */
+ protected $didSetup = false;
+
+ protected $isApiRequest = false;
+
+ protected $successCallbacks = [];
+
+ protected $calledSuccessCallbacks = false;
+
+ protected $onRequestCallbacks = [];
+
+ protected $calledOnRequestCallbacks = false;
+
+ public function __construct($options = null)
+ {
+ parent::__construct($options);
+
+ $this->setMethod('post');
+ $this->getActionFromRequest()
+ ->createIdElement()
+ ->regenerateCsrfToken()
+ ->setPreferredDecorators();
+ }
+
+ protected function getActionFromRequest()
+ {
+ $this->setAction(Url::fromRequest());
+ return $this;
+ }
+
+ protected function setPreferredDecorators()
+ {
+ $current = $this->getAttrib('class');
+ $current .= ' director-form';
+ if ($current) {
+ $this->setAttrib('class', "$current autofocus");
+ } else {
+ $this->setAttrib('class', 'autofocus');
+ }
+ $this->setDecorators(
+ array(
+ 'Description',
+ array('FormErrors', array('onlyCustomFormErrors' => true)),
+ 'FormElements',
+ 'Form'
+ )
+ );
+
+ return $this;
+ }
+
+ protected function addSubmitButton($label, $options = [])
+ {
+ $el = $this->createElement('submit', $label, $options)
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ $this->submitButtonName = $el->getName();
+ $this->setSubmitLabel($label);
+ $this->addElement($el);
+ }
+
+ protected function addStandaloneSubmitButton($label, $options = [])
+ {
+ $this->addSubmitButton($label, $options);
+ $this->addDisplayGroup([$this->submitButtonName], 'buttons', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'p')),
+ ),
+ 'order' => 1000,
+ ));
+ }
+
+ protected function addSubmitButtonIfSet()
+ {
+ if (false === ($label = $this->getSubmitLabel())) {
+ return;
+ }
+
+ if ($this->submitButtonName && $el = $this->getElement($this->submitButtonName)) {
+ return;
+ }
+
+ $this->addSubmitButton($label);
+
+ $fakeEl = $this->createElement('submit', '_FAKE_SUBMIT', array(
+ 'role' => 'none',
+ 'tabindex' => '-1',
+ ))
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ $this->fakeSubmitButtonName = $fakeEl->getName();
+ $this->addElement($fakeEl);
+
+ $this->addDisplayGroup(
+ array($this->fakeSubmitButtonName),
+ 'fake_button',
+ array(
+ 'decorators' => array('FormElements'),
+ 'order' => 1,
+ )
+ );
+
+ $this->addButtonDisplayGroup();
+ }
+
+ protected function addButtonDisplayGroup()
+ {
+ $grp = array(
+ $this->submitButtonName,
+ $this->deleteButtonName
+ );
+ $this->addDisplayGroup($grp, 'buttons', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'DtDdWrapper',
+ ),
+ 'order' => 1000,
+ ));
+ }
+
+ protected function addSimpleDisplayGroup($elements, $name, $options)
+ {
+ if (! array_key_exists('decorators', $options)) {
+ $options['decorators'] = array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ );
+ }
+
+ return $this->addDisplayGroup($elements, $name, $options);
+ }
+
+ protected function createIdElement()
+ {
+ if ($this->isApiRequest()) {
+ return $this;
+ }
+ $this->detectName();
+ $this->addHidden(self::ID, $this->getName());
+ $this->getElement(self::ID)->setIgnore(true);
+ return $this;
+ }
+
+ public function getSentValue($name, $default = null)
+ {
+ $request = $this->getRequest();
+ if ($request->isPost() && $this->hasBeenSent()) {
+ return $request->getPost($name);
+ } else {
+ return $default;
+ }
+ }
+
+ public function getSubmitLabel()
+ {
+ if ($this->submitLabel === null) {
+ return $this->translate('Submit');
+ }
+
+ return $this->submitLabel;
+ }
+
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+ return $this;
+ }
+
+ public function setApiRequest($isApiRequest = true)
+ {
+ $this->isApiRequest = $isApiRequest;
+ return $this;
+ }
+
+ public function isApiRequest()
+ {
+ if ($this->isApiRequest === null) {
+ if ($this->request === null) {
+ throw new RuntimeException(
+ 'Early access to isApiRequest(). This is not possible, sorry'
+ );
+ }
+
+ return $this->getRequest()->isApiRequest();
+ } else {
+ return $this->isApiRequest;
+ }
+ }
+
+ public function regenerateCsrfToken()
+ {
+ if ($this->isApiRequest()) {
+ return $this;
+ }
+ if (! $element = $this->getElement(self::CSRF)) {
+ $this->addHidden(self::CSRF, CsrfToken::generate());
+ $element = $this->getElement(self::CSRF);
+ }
+ $element->setIgnore(true);
+
+ return $this;
+ }
+
+ public function removeCsrfToken()
+ {
+ $this->removeElement(self::CSRF);
+ return $this;
+ }
+
+ public function setSuccessUrl($url, $params = null)
+ {
+ if (! $url instanceof Url) {
+ $url = Url::fromPath($url);
+ }
+ if ($params !== null) {
+ $url->setParams($params);
+ }
+ $this->successUrl = $url;
+ return $this;
+ }
+
+ public function getSuccessUrl()
+ {
+ $url = $this->successUrl ?: $this->getAction();
+ if (! $url instanceof Url) {
+ $url = Url::fromPath($url);
+ }
+
+ return $url;
+ }
+
+ protected function beforeSetup()
+ {
+ }
+
+ public function setup()
+ {
+ }
+
+ protected function onSetup()
+ {
+ }
+
+ public function setAction($action)
+ {
+ if ($action instanceof Url) {
+ $action = $action->getAbsoluteUrl('&');
+ }
+
+ return parent::setAction($action);
+ }
+
+ public function hasBeenSubmitted()
+ {
+ if ($this->hasBeenSubmitted === null) {
+ $req = $this->getRequest();
+ if ($req->isApiRequest()) {
+ return $this->hasBeenSubmitted = true;
+ }
+ if ($req->isPost()) {
+ if (! $this->hasSubmitButton()) {
+ return $this->hasBeenSubmitted = $this->hasBeenSent();
+ }
+
+ $this->hasBeenSubmitted = $this->pressedButton(
+ $this->fakeSubmitButtonName,
+ $this->getSubmitLabel()
+ ) || $this->pressedButton(
+ $this->submitButtonName,
+ $this->getSubmitLabel()
+ );
+ } else {
+ $this->hasBeenSubmitted = false;
+ }
+ }
+
+ return $this->hasBeenSubmitted;
+ }
+
+ protected function hasSubmitButton()
+ {
+ return $this->submitButtonName !== null;
+ }
+
+ protected function pressedButton($name, $label)
+ {
+ $req = $this->getRequest();
+ if (! $req->isPost()) {
+ return false;
+ }
+
+ $req = $this->getRequest();
+ $post = $req->getPost();
+
+ return array_key_exists($name, $post)
+ && $post[$name] === $label;
+ }
+
+ protected function beforeValidation($data = array())
+ {
+ }
+
+ public function prepareElements()
+ {
+ if (! $this->didSetup) {
+ $this->beforeSetup();
+ $this->setup();
+ $this->onSetup();
+ $this->didSetup = true;
+ }
+
+ return $this;
+ }
+
+ public function handleRequest(Request $request = null)
+ {
+ if ($request === null) {
+ $request = $this->getRequest();
+ } else {
+ $this->setRequest($request);
+ }
+
+ $this->prepareElements();
+ $this->addSubmitButtonIfSet();
+
+ if ($this->hasBeenSent()) {
+ $post = $request->getPost();
+ if ($this->hasBeenSubmitted()) {
+ $this->beforeValidation($post);
+ if ($this->isValid($post)) {
+ try {
+ $this->onSuccess();
+ $this->callOnSuccessCallables();
+ } catch (Exception $e) {
+ $this->addException($e);
+ $this->onFailure();
+ }
+ } else {
+ $this->onFailure();
+ }
+ } else {
+ $this->setDefaults($post);
+ }
+ }
+
+ return $this;
+ }
+
+ public function addException(Exception $e, $elementName = null)
+ {
+ $msg = $this->getErrorMessageForException($e);
+ if ($el = $this->getElement($elementName)) {
+ $el->addError($msg);
+ } else {
+ $this->addError($msg);
+ }
+ }
+
+ public function addUniqueErrorMessage($msg)
+ {
+ if (! in_array($msg, $this->getErrorMessages())) {
+ $this->addErrorMessage($msg);
+ }
+
+ return $this;
+ }
+
+ public function addUniqueException(Exception $e)
+ {
+ $msg = $this->getErrorMessageForException($e);
+
+ if (! in_array($msg, $this->getErrorMessages())) {
+ $this->addErrorMessage($msg);
+ }
+
+ return $this;
+ }
+
+ protected function getErrorMessageForException(Exception $e)
+ {
+ $file = preg_split('/[\/\\\]/', $e->getFile(), -1, PREG_SPLIT_NO_EMPTY);
+ $file = array_pop($file);
+ return sprintf(
+ '%s (%s:%d)',
+ $e->getMessage(),
+ $file,
+ $e->getLine()
+ );
+ }
+
+ public function onSuccess()
+ {
+ $this->redirectOnSuccess();
+ }
+
+ /**
+ * @param callable $callable
+ * @return $this
+ */
+ public function callOnRequest($callable)
+ {
+ if (! is_callable($callable)) {
+ throw new InvalidArgumentException(
+ 'callOnRequest() expects a callable'
+ );
+ }
+ $this->onRequestCallbacks[] = $callable;
+
+ return $this;
+ }
+
+ protected function callOnRequestCallables()
+ {
+ if (! $this->calledOnRequestCallbacks) {
+ $this->calledOnRequestCallbacks = true;
+ foreach ($this->onRequestCallbacks as $callable) {
+ $callable($this);
+ }
+ }
+ }
+
+ /**
+ * @param callable $callable
+ * @return $this
+ */
+ public function callOnSuccess($callable)
+ {
+ if (! is_callable($callable)) {
+ throw new InvalidArgumentException(
+ 'callOnSuccess() expects a callable'
+ );
+ }
+ $this->successCallbacks[] = $callable;
+
+ return $this;
+ }
+
+ protected function callOnSuccessCallables()
+ {
+ if (! $this->calledSuccessCallbacks) {
+ $this->calledSuccessCallbacks = true;
+ foreach ($this->successCallbacks as $callable) {
+ $callable($this);
+ }
+ }
+ }
+
+ public function setSuccessMessage($message)
+ {
+ $this->successMessage = $message;
+ return $this;
+ }
+
+ public function getSuccessMessage($message = null)
+ {
+ if ($message !== null) {
+ return $message;
+ }
+ if ($this->successMessage === null) {
+ return t('Form has successfully been sent');
+ }
+ return $this->successMessage;
+ }
+
+ public function redirectOnSuccess($message = null)
+ {
+ if ($this->isApiRequest()) {
+ // TODO: Set the status line message?
+ $this->successMessage = $this->getSuccessMessage($message);
+ $this->callOnSuccessCallables();
+ return;
+ }
+
+ $url = $this->getSuccessUrl();
+ $this->callOnSuccessCallables();
+ $this->notifySuccess($this->getSuccessMessage($message));
+ $this->redirectAndExit($url);
+ }
+
+ public function onFailure()
+ {
+ }
+
+ public function notifySuccess($message = null)
+ {
+ if ($message === null) {
+ $message = t('Form has successfully been sent');
+ }
+ Notification::success($message);
+ return $this;
+ }
+
+ public function notifyError($message)
+ {
+ Notification::error($message);
+ return $this;
+ }
+
+ protected function redirectAndExit($url)
+ {
+ /** @var Response $response */
+ $response = Icinga::app()->getFrontController()->getResponse();
+ $response->redirectAndExit($url);
+ }
+
+ protected function setHttpResponseCode($code)
+ {
+ Icinga::app()->getFrontController()->getResponse()->setHttpResponseCode($code);
+ return $this;
+ }
+
+ protected function onRequest()
+ {
+ $this->callOnRequestCallables();
+ }
+
+ public function setRequest(Request $request)
+ {
+ if ($this->request !== null) {
+ throw new RuntimeException('Unable to set request twice');
+ }
+
+ $this->request = $request;
+ $this->prepareElements();
+ $this->onRequest();
+ $this->callOnRequestCallables();
+
+ return $this;
+ }
+
+ /**
+ * @return Request
+ */
+ public function getRequest()
+ {
+ if ($this->request === null) {
+ /** @var Request $request */
+ $request = Icinga::app()->getFrontController()->getRequest();
+ $this->setRequest($request);
+ }
+ return $this->request;
+ }
+
+ public function hasBeenSent()
+ {
+ if ($this->hasBeenSent === null) {
+
+ /** @var Request $req */
+ if ($this->request === null) {
+ $req = Icinga::app()->getFrontController()->getRequest();
+ } else {
+ $req = $this->request;
+ }
+
+ if ($req->isApiRequest()) {
+ $this->hasBeenSent = true;
+ } elseif ($req->isPost()) {
+ $post = $req->getPost();
+ $this->hasBeenSent = array_key_exists(self::ID, $post) &&
+ $post[self::ID] === $this->getName();
+ } else {
+ $this->hasBeenSent = false;
+ }
+ }
+
+ return $this->hasBeenSent;
+ }
+
+ protected function detectName()
+ {
+ if ($this->formName !== null) {
+ $this->setName($this->formName);
+ } else {
+ $this->setName(get_class($this));
+ }
+ }
+}
diff --git a/library/Director/Web/Form/QuickSubForm.php b/library/Director/Web/Form/QuickSubForm.php
new file mode 100644
index 0000000..2487d35
--- /dev/null
+++ b/library/Director/Web/Form/QuickSubForm.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+abstract class QuickSubForm extends QuickBaseForm
+{
+ /**
+ * Whether or not form elements are members of an array
+ * @codingStandardsIgnoreStart
+ * @var bool
+ */
+ protected $_isArray = true;
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * Load the default decorators
+ *
+ * @return $this
+ */
+ public function loadDefaultDecorators()
+ {
+ if ($this->loadDefaultDecoratorsIsDisabled()) {
+ return $this;
+ }
+
+ $decorators = $this->getDecorators();
+ if (empty($decorators)) {
+ $this->addDecorator('FormElements')
+ ->addDecorator('HtmlTag', array('tag' => 'dl'))
+ ->addDecorator('Fieldset')
+ ->addDecorator('DtDdWrapper');
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Web/Form/Validate/IsDataListEntry.php b/library/Director/Web/Form/Validate/IsDataListEntry.php
new file mode 100644
index 0000000..5762d2e
--- /dev/null
+++ b/library/Director/Web/Form/Validate/IsDataListEntry.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Validate;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDatalistEntry;
+use Zend_Validate_Abstract;
+
+class IsDataListEntry extends Zend_Validate_Abstract
+{
+ const INVALID = 'intInvalid';
+
+ /** @var Db */
+ private $db;
+
+ /** @var int */
+ private $dataListId;
+
+ public function __construct($dataListId, Db $db)
+ {
+ $this->db = $db;
+ $this->dataListId = (int) $dataListId;
+ }
+
+ public function isValid($value)
+ {
+ if (is_array($value)) {
+ foreach ($value as $name) {
+ if (! $this->isListEntry($name)) {
+ $this->_error(self::INVALID, $value);
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ if ($this->isListEntry($value)) {
+ return true;
+ } else {
+ $this->_error(self::INVALID, $value);
+
+ return false;
+ }
+ }
+
+ protected function isListEntry($name)
+ {
+ return DirectorDatalistEntry::exists([
+ 'list_id' => $this->dataListId,
+ 'entry_name' => $name,
+ ], $this->db);
+ }
+}
diff --git a/library/Director/Web/Form/Validate/NamePattern.php b/library/Director/Web/Form/Validate/NamePattern.php
new file mode 100644
index 0000000..fac44d9
--- /dev/null
+++ b/library/Director/Web/Form/Validate/NamePattern.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Validate;
+
+use Icinga\Module\Director\Restriction\MatchingFilter;
+use Zend_Validate_Abstract;
+
+class NamePattern extends Zend_Validate_Abstract
+{
+ const INVALID = 'intInvalid';
+
+ private $filter;
+
+ public function __construct($pattern)
+ {
+ if (! is_array($pattern)) {
+ $pattern = [$pattern];
+ }
+
+ $this->filter = MatchingFilter::forPatterns($pattern, 'value');
+
+ $this->_messageTemplates[self::INVALID] = sprintf(
+ 'Does not match %s',
+ (string) $this->filter
+ );
+ }
+
+ public function isValid($value)
+ {
+ if ($this->filter->matches((object) ['value' => $value])) {
+ return true;
+ } else {
+ $this->_error(self::INVALID, $value);
+
+ return false;
+ }
+ }
+}
diff --git a/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php
new file mode 100644
index 0000000..1aabada
--- /dev/null
+++ b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Navigation\Renderer;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Web;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchStore;
+use Icinga\Module\Director\Db\Migrations;
+use Icinga\Module\Director\KickstartHelper;
+use Icinga\Module\Director\Web\Controller\Extension\DirectorDb;
+use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
+use Icinga\Module\Director\Web\Window;
+
+class ConfigHealthItemRenderer extends BadgeNavigationItemRenderer
+{
+ use DirectorDb;
+
+ private $directorState = self::STATE_OK;
+
+ private $message;
+
+ private $count = 0;
+
+ private $window;
+
+ protected function hasProblems()
+ {
+ try {
+ $this->checkHealth();
+ } catch (Exception $e) {
+ $this->directorState = self::STATE_UNKNOWN;
+ $this->count = 1;
+ $this->message = $e->getMessage();
+ }
+
+ return $this->count > 0;
+ }
+
+ public function getState()
+ {
+ return $this->directorState;
+ }
+
+ public function getCount()
+ {
+ if ($this->hasProblems()) {
+ return $this->count;
+ } else {
+ return 0;
+ }
+ }
+
+ public function getTitle()
+ {
+ return $this->message;
+ }
+
+ protected function checkHealth()
+ {
+ $db = $this->db();
+ if (! $db) {
+ $this->directorState = self::STATE_PENDING;
+ $this->count = 1;
+ $this->message = $this->translate(
+ 'No database has been configured for Icinga Director'
+ );
+
+ return;
+ }
+
+ $migrations = new Migrations($db);
+ if (!$migrations->hasSchema()) {
+ $this->count = 1;
+ $this->directorState = self::STATE_CRITICAL;
+ $this->message = $this->translate(
+ 'Director database schema has not been created yet'
+ );
+ return;
+ }
+
+ if ($migrations->hasPendingMigrations()) {
+ $this->count = $migrations->countPendingMigrations();
+ $this->directorState = self::STATE_PENDING;
+ $this->message = sprintf(
+ $this->translate('There are %d pending database migrations'),
+ $this->count
+ );
+ return;
+ }
+
+ $kickstart = new KickstartHelper($db);
+ if ($kickstart->isRequired()) {
+ $this->directorState = self::STATE_PENDING;
+ $this->count = 1;
+ $this->message = $this->translate(
+ 'No API user configured, you might run the kickstart helper'
+ );
+
+ return;
+ }
+
+ $branch = Branch::detect(new BranchStore($this->db()));
+ if ($branch->isBranch()) {
+ $count = $branch->getActivityCount();
+ if ($count > 0) {
+ $this->directorState = self::STATE_PENDING;
+ $this->count = $count;
+ $this->message = sprintf(
+ $this->translate('%s config changes are available in your configuration branch'),
+ $count
+ );
+ }
+
+ return;
+ }
+
+ $pendingChanges = $db->countActivitiesSinceLastDeployedConfig();
+
+ if ($pendingChanges > 0) {
+ $this->directorState = self::STATE_WARNING;
+ $this->count = $pendingChanges;
+ $this->message = sprintf(
+ $this->translate(
+ '%s config changes happend since the last deployed configuration'
+ ),
+ $pendingChanges
+ );
+ }
+ }
+
+ protected function translate($message)
+ {
+ return mt('director', $message);
+ }
+
+ protected function db()
+ {
+ try {
+ $resourceName = Config::module('director')->get('db', 'resource');
+ if ($resourceName) {
+ // Window might have switched to another DB:
+ return Db::fromResourceName($this->getDbResourceName());
+ } else {
+ return false;
+ }
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * TODO: the following methods are for the DirectorDb trait, we need
+ * something better in future. It is required to show Health
+ * related to the DB chosen in the current Window
+ *
+ * @codingStandardsIgnoreStart
+ * @return Auth
+ */
+ protected function Auth()
+ {
+ return Auth::getInstance();
+ }
+
+ /**
+ * @return Window
+ */
+ public function Window()
+ {
+ if ($this->window === null) {
+ try {
+ /** @var $app Web */
+ $app = Icinga::app();
+ $this->window = new Window(
+ $app->getRequest()->getHeader('X-Icinga-WindowId')
+ );
+ } catch (Exception $e) {
+ $this->window = new Window(Window::UNDEFINED);
+ }
+ }
+ return $this->window;
+ }
+
+ /**
+ * @return Config
+ */
+ protected function Config()
+ {
+ // @codingStandardsIgnoreEnd
+ return Config::module('director');
+ }
+}
diff --git a/library/Director/Web/ObjectPreview.php b/library/Director/Web/ObjectPreview.php
new file mode 100644
index 0000000..e7648e1
--- /dev/null
+++ b/library/Director/Web/ObjectPreview.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Icinga\Module\Director\Web;
+
+use gipfl\Web\Widget\Hint;
+use ipl\Html\Text;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Web\Request;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+
+class ObjectPreview
+{
+ use TranslationHelper;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var Request */
+ protected $request;
+
+ public function __construct(IcingaObject $object, Request $request)
+ {
+ $this->object = $object;
+ $this->request = $request;
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function renderTo(ControlsAndContent $cc)
+ {
+ $object = $this->object;
+ $url = $this->request->getUrl();
+ $params = $url->getParams();
+ $cc->addTitle(
+ $this->translate('Config preview: %s'),
+ $object->getObjectName()
+ );
+
+ if ($params->shift('resolved')) {
+ $object = $object::fromPlainObject(
+ $object->toPlainObject(true),
+ $object->getConnection()
+ );
+
+ $cc->actions()->add(Link::create(
+ $this->translate('Show normal'),
+ $url->without('resolved'),
+ null,
+ ['class' => 'icon-resize-small state-warning']
+ ));
+ } else {
+ try {
+ if ($object->supportsImports() && $object->imports()->count() > 0) {
+ $cc->actions()->add(Link::create(
+ $this->translate('Show resolved'),
+ $url->with('resolved', true),
+ null,
+ ['class' => 'icon-resize-full']
+ ));
+ }
+ } catch (NestingError $e) {
+ // No resolve link with nesting errors
+ }
+ }
+
+ $content = $cc->content();
+ if ($object->isDisabled()) {
+ $content->add(Hint::error(
+ $this->translate('This object will not be deployed as it has been disabled')
+ ));
+ }
+ if ($object->isExternal()) {
+ $content->add(Html::tag('p', null, $this->translate((
+ 'This is an external object. It has been imported from Icinga 2 through the'
+ . ' Core API and cannot be managed with the Icinga Director. It is however'
+ . ' perfectly valid to create objects using this or referring to this object.'
+ . ' You might also want to define related Fields to make work based on this'
+ . ' object more enjoyable.'
+ ))));
+ }
+ $config = $object->toSingleIcingaConfig();
+
+ foreach ($config->getFiles() as $filename => $file) {
+ if (! $object->isExternal()) {
+ $content->add(Html::tag('h2', null, $filename));
+ }
+
+ $classes = array();
+ if ($object->isDisabled()) {
+ $classes[] = 'disabled';
+ } elseif ($object->isExternal()) {
+ $classes[] = 'logfile';
+ }
+
+ $type = $object->getShortTableName();
+
+ $plain = Html::wantHtml($file->getContent())->render();
+ $plain = preg_replace_callback(
+ '/^(\s+import\s+\&quot\;)(.+)(\&quot\;)/m',
+ [$this, 'linkImport'],
+ $plain
+ );
+
+ if ($type !== 'command') {
+ $plain = preg_replace_callback(
+ '/^(\s+(?:check_|event_)?command\s+=\s+\&quot\;)(.+)(\&quot\;)/m',
+ [$this, 'linkCommand'],
+ $plain
+ );
+ }
+
+ $plain = preg_replace_callback(
+ '/^(\s+host_name\s+=\s+\&quot\;)(.+)(\&quot\;)/m',
+ [$this, 'linkHost'],
+ $plain
+ );
+ $text = Text::create($plain)->setEscaped();
+
+ $content->add(Html::tag('pre', ['class' => $classes], $text));
+ }
+ }
+
+ /**
+ * @api internal
+ * @param $match
+ * @return string
+ */
+ public function linkImport($match)
+ {
+ $blacklist = [
+ 'plugin-notification-command',
+ 'plugin-check-command',
+ ];
+ if (in_array($match[2], $blacklist)) {
+ return $match[1] . $match[2] . $match[3];
+ }
+
+ $urlObjectType = $this->object->getShortTableName();
+ if ($urlObjectType === 'service_set') {
+ $urlObjectType = 'service';
+ }
+ return $match[1] . Link::create(
+ $match[2],
+ sprintf("director/$urlObjectType"),
+ ['name' => $match[2]]
+ )->render() . $match[3];
+ }
+
+ /**
+ * @api internal
+ * @param $match
+ * @return string
+ */
+ public function linkCommand($match)
+ {
+ return $match[1] . Link::create(
+ $match[2],
+ sprintf('director/command'),
+ ['name' => $match[2]]
+ )->render() . $match[3];
+ }
+
+ /**
+ * @api internal
+ * @param $match
+ * @return string
+ */
+ public function linkHost($match)
+ {
+ return $match[1] . Link::create(
+ $match[2],
+ sprintf('director/host'),
+ ['name' => $match[2]]
+ )->render() . $match[3];
+ }
+}
diff --git a/library/Director/Web/SelfService.php b/library/Director/Web/SelfService.php
new file mode 100644
index 0000000..33756b7
--- /dev/null
+++ b/library/Director/Web/SelfService.php
@@ -0,0 +1,311 @@
+<?php
+
+namespace Icinga\Module\Director\Web;
+
+use Exception;
+use gipfl\Web\Widget\Hint;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Forms\IcingaForgetApiKeyForm;
+use Icinga\Module\Director\Forms\IcingaGenerateApiKeyForm;
+use Icinga\Application\Icinga;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\IcingaConfig\AgentWizard;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Director\Web\Widget\Documentation;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ActionBar;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+
+class SelfService
+{
+ use TranslationHelper;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ /** @var CoreApi */
+ protected $api;
+
+ public function __construct(IcingaHost $host, CoreApi $api)
+ {
+ $this->host = $host;
+ $this->api = $api;
+ }
+
+ /**
+ * @param ControlsAndContent $controller
+ */
+ public function renderTo(ControlsAndContent $controller)
+ {
+ $host = $this->host;
+ if ($host->isTemplate()) {
+ $this->showSelfServiceTemplateInstructions($controller);
+ } elseif ($key = $host->getProperty('api_key')) {
+ $this->showRegisteredAgentInstructions($key, $controller);
+ } elseif ($key = $host->getSingleResolvedProperty('api_key')) {
+ $this->showNewAgentInstructions($controller);
+ } else {
+ $this->showLegacyAgentInstructions($controller);
+ }
+ }
+
+ /**
+ * @param string $key
+ * @param ControlsAndContent $c
+ */
+ protected function showRegisteredAgentInstructions($key, ControlsAndContent $c)
+ {
+ $c->addTitle($this->translate('Registered Agent'));
+ $c->content()->add([
+ Html::tag('p', null, $this->translate(
+ 'This host has been registered via the Icinga Director Self Service'
+ . " API. In case you re-installed the host or somehow lost it's"
+ . ' secret key, you might want to dismiss the current key. This'
+ . ' would allow you to register the same host again.'
+ )),
+ Html::tag('p', null, [$this->translate('Api Key:'), ' ', Html::tag('strong', null, $key)]),
+ Hint::warning($this->translate(
+ 'It is not a good idea to do so as long as your Agent still has'
+ . ' a valid Self Service API key!'
+ )),
+ IcingaForgetApiKeyForm::load()->setHost($this->host)->handleRequest()
+ ]);
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ */
+ protected function showSelfServiceTemplateInstructions(ControlsAndContent $cc)
+ {
+ $host = $this->host;
+ $key = $host->getProperty('api_key');
+ $hasKey = $key !== null;
+ if ($hasKey) {
+ $cc->addTitle($this->translate('Shared for Self Service API'));
+ } else {
+ $cc->addTitle($this->translate('Share this Template for Self Service API'));
+ }
+
+ $c = $cc->content();
+ /** @var ActionBar $actions */
+ $actions = $cc->actions();
+ $actions->setBaseTarget('_next')->add(Link::create(
+ $this->translate('Settings'),
+ 'director/settings/self-service',
+ null,
+ [
+ 'title' => $this->translate('Global Self Service Setting'),
+ 'class' => 'icon-services',
+ ]
+ ));
+
+ $actions->add($this->getDocumentationLink());
+
+ if ($hasKey) {
+ $c->add([
+ Html::tag('p', [
+ $this->translate('Api Key:'), ' ', Html::tag('strong', null, $key)
+ ]),
+ $this->getWindowsInstructions($host, $key),
+ Html::tag('h2', null, $this->translate('Generate a new key')),
+ Hint::warning($this->translate(
+ 'This will invalidate the former key'
+ )),
+ ]);
+ }
+
+ $c->add([
+ // Html::tag('p', null, $this->translate('..')),
+ IcingaGenerateApiKeyForm::load()->setHost($host)->handleRequest()
+ ]);
+ if ($hasKey) {
+ $c->add([
+ Html::tag('h2', null, $this->translate('Stop sharing this Template')),
+ Html::tag('p', null, $this->translate(
+ 'You can stop sharing a Template at any time. This will'
+ . ' immediately invalidate the former key.'
+ ) . ' ' . $this->translate(
+ 'Generated Host keys will continue to work, but you\'ll no'
+ . ' longer be able to register new Hosts with this key'
+ )),
+ IcingaForgetApiKeyForm::load()->setHost($host)->handleRequest()
+ ]);
+ }
+ }
+
+ protected function getWindowsInstructions($host, $key)
+ {
+ $wizard = new AgentWizard($host);
+
+ return [
+ Html::tag('h2', $this->translate('Icinga for Windows')),
+ Html::tag('p', Html::sprintf(
+ $this->translate('In case you\'re using %s, please run this Script:'),
+ Html::tag('a', [
+ 'href' => 'https://icinga.com/docs/windows/latest/',
+ 'target' => '_blank',
+ ], $this->translate('Icinga for Windows'))
+ )),
+ Html::tag(
+ 'pre',
+ ['class' => 'logfile'],
+ $wizard->renderIcinga4WindowsWizardCommand($key)
+ ),
+ Html::tag('h3', $this->translate('Icinga 2 Powershell Module')),
+ Html::tag('p', Html::sprintf(
+ $this->translate('In case you\'re using the legacy %s, please run:'),
+ Html::tag('a', [
+ 'href' => 'https://github.com/Icinga/icinga2-powershell-module',
+ 'target' => '_blank',
+ ], $this->translate('Icinga 2 Powershell Module'))
+ )),
+ Html::tag(
+ 'pre',
+ ['class' => 'logfile'],
+ $wizard->renderPowershellModuleInstaller($key)
+ ),
+ ];
+ }
+
+ protected function getDocumentationLink()
+ {
+ return Documentation::link(
+ $this->translate('Documentation'),
+ 'director',
+ '74-Self-Service-API',
+ $this->translate('Self Service API')
+ );
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ */
+ protected function showNewAgentInstructions(ControlsAndContent $cc)
+ {
+ $content = $cc->content();
+ $host = $this->host;
+ $key = $host->getSingleResolvedProperty('api_key');
+ $cc->addTitle($this->translate('Configure this Agent via Self Service API'));
+ $cc->actions()->add($this->getDocumentationLink());
+ $content->add(Html::tag('p', [
+ $this->translate('Inherited Template Api Key:'), ' ', Html::tag('strong', null, $key)
+ ]));
+ $content->add($this->getWindowsInstructions($host, $key));
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ */
+ protected function showLegacyAgentInstructions(ControlsAndContent $cc)
+ {
+ $host = $this->host;
+ $c = $cc->content();
+ $docBaseUrl = 'https://docs.icinga.com/icinga2/latest/doc/module/icinga2/chapter/distributed-monitoring';
+ $sectionSetup = 'distributed-monitoring-setup-satellite-client';
+ $sectionTopDown = 'distributed-monitoring-top-down';
+ $c->add(Html::tag('p')->add(Html::sprintf(
+ 'Please check the %s for more related information.'
+ . ' The Director-assisted setup corresponds to configuring a %s environment.',
+ Html::tag(
+ 'a',
+ ['href' => $docBaseUrl . '#' . $sectionSetup],
+ $this->translate('Icinga 2 Client documentation')
+ ),
+ Html::tag(
+ 'a',
+ ['href' => $docBaseUrl . '#' . $sectionTopDown],
+ $this->translate('Top Down')
+ )
+ )));
+
+ $cc->addTitle('Agent deployment instructions');
+
+ try {
+ $ticket = $this->api->getTicket($host->getEndpointName());
+ $wizard = new AgentWizard($host);
+ $wizard->setTicket($ticket);
+ } catch (Exception $e) {
+ $c->add(Hint::error(sprintf(
+ $this->translate(
+ 'A ticket for this agent could not have been requested from'
+ . ' your deployment endpoint: %s'
+ ),
+ $e->getMessage()
+ )));
+
+ return;
+ }
+
+ $class = ['class' => 'agent-deployment-instructions'];
+ $c->add([
+ Html::tag('h2', null, $this->translate('For manual configuration')),
+ Html::tag('p', null, [$this->translate('Ticket'), ': ', Html::tag('code', null, $ticket)]),
+ Html::tag('h2', null, $this->translate('Windows Kickstart Script')),
+ Link::create(
+ $this->translate('Download'),
+ $cc->url()->with('download', 'windows-kickstart'),
+ null,
+ ['class' => 'icon-download', 'target' => '_blank']
+ ),
+ Html::tag('pre', $class, $wizard->renderWindowsInstaller()),
+ Html::tag('p', null, $this->translate(
+ 'This requires the Icinga Agent to be installed. It generates and signs'
+ . ' it\'s certificate and it also generates a minimal icinga2.conf to get'
+ . ' your agent connected to it\'s parents'
+ )),
+ Html::tag('h2', null, $this->translate('Linux commandline')),
+ Link::create(
+ $this->translate('Download'),
+ $cc->url()->with('download', 'linux'),
+ null,
+ ['class' => 'icon-download', 'target' => '_blank']
+ ),
+ Html::tag('p', null, $this->translate('Just download and run this script on your Linux Client Machine:')),
+ Html::tag('pre', $class, $wizard->renderLinuxInstaller())
+ ]);
+ }
+
+ /**
+ * @param $os
+ * @throws NotFoundError
+ */
+ public function handleLegacyAgentDownloads($os)
+ {
+ $wizard = new AgentWizard($this->host);
+ $wizard->setTicket($this->api->getTicket($this->host->getEndpointName()));
+
+ switch ($os) {
+ case 'windows-kickstart':
+ $ext = 'ps1';
+ $script = preg_replace('/\n/', "\r\n", $wizard->renderWindowsInstaller());
+ break;
+ case 'linux':
+ $ext = 'bash';
+ $script = $wizard->renderLinuxInstaller();
+ break;
+ default:
+ throw new NotFoundError('There is no kickstart helper for %s', $os);
+ }
+
+ header('Content-type: application/octet-stream');
+ header('Content-Disposition: attachment; filename=icinga2-agent-kickstart.' . $ext);
+ echo $script;
+ exit;
+ }
+
+ /**
+ * @return bool
+ */
+ protected function hasDocsModuleLoaded()
+ {
+ try {
+ return Icinga::app()->getModuleManager()->hasLoaded('doc');
+ } catch (ProgrammingError $e) {
+ return false;
+ }
+ }
+}
diff --git a/library/Director/Web/Table/ActivityLogTable.php b/library/Director/Web/Table/ActivityLogTable.php
new file mode 100644
index 0000000..5460bc2
--- /dev/null
+++ b/library/Director/Web/Table/ActivityLogTable.php
@@ -0,0 +1,294 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\Format\LocalTimeFormat;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Module\Director\Util;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+
+class ActivityLogTable extends ZfQueryBasedTable
+{
+ protected $filters = [];
+
+ protected $lastDeployedId;
+
+ protected $extraParams = [];
+
+ protected $columnCount;
+
+ protected $hasObjectFilter = false;
+
+ protected $searchColumns = [
+ 'author',
+ 'object_name',
+ 'object_type',
+ ];
+
+ /** @var LocalTimeFormat */
+ protected $timeFormat;
+
+ protected $ranges = [];
+
+ /** @var ?object */
+ protected $currentRange = null;
+ /** @var ?HtmlElement */
+ protected $currentRangeCell = null;
+ /** @var int */
+ protected $rangeRows = 0;
+ protected $continueRange = false;
+ protected $currentRow;
+
+ public function __construct($db)
+ {
+ parent::__construct($db);
+ $this->timeFormat = new LocalTimeFormat();
+ }
+
+ public function assemble()
+ {
+ $this->getAttributes()->add('class', 'activity-log');
+ }
+
+ public function setLastDeployedId($id)
+ {
+ $this->lastDeployedId = $id;
+ return $this;
+ }
+
+ protected function fetchQueryRows()
+ {
+ $rows = parent::fetchQueryRows();
+ // Hint -> DESC, that's why they are inverted
+ if (empty($rows)) {
+ return $rows;
+ }
+ $last = $rows[0]->id;
+ $first = $rows[count($rows) - 1]->id;
+ $db = $this->db();
+ $this->ranges = $db->fetchAll(
+ $db->select()
+ ->from('director_activity_log_remark')
+ ->where('first_related_activity <= ?', $last)
+ ->where('last_related_activity >= ?', $first)
+ );
+
+ return $rows;
+ }
+
+
+ public function renderRow($row)
+ {
+ $this->currentRow = $row;
+ $this->splitByDay($row->ts_change_time);
+ $action = 'action-' . $row->action. ' ';
+ if ($row->id > $this->lastDeployedId) {
+ $action .= 'undeployed';
+ } else {
+ $action .= 'deployed';
+ }
+
+ $columns = [
+ $this::td($this->makeLink($row))->setSeparator(' '),
+ ];
+ if (! $this->hasObjectFilter) {
+ $columns[] = $this->makeRangeInfo($row->id);
+ }
+ $columns[] = $this::td($this->timeFormat->getTime($row->ts_change_time));
+
+ return $this::tr($columns)->addAttributes(['class' => $action]);
+ }
+
+ /**
+ * Hint: cloned from parent class and modified
+ * @param int $timestamp
+ */
+ protected function renderDayIfNew($timestamp)
+ {
+ $day = $this->getDateFormatter()->getFullDay($timestamp);
+
+ if ($this->lastDay !== $day) {
+ $this->nextHeader()->add(
+ $this::th($day, [
+ 'colspan' => $this->hasObjectFilter ? 2 : 3,
+ 'class' => 'table-header-day'
+ ])
+ );
+
+ $this->lastDay = $day;
+ if ($this->currentRangeCell) {
+ if ($this->currentRange->first_related_activity <= $this->currentRow->id) {
+ $this->currentRangeCell->addAttributes(['class' => 'continuing']);
+ $this->continueRange = true;
+ } else {
+ $this->continueRange = false;
+ }
+ }
+ $this->currentRangeCell = null;
+ $this->currentRange = null;
+ $this->rangeRows = 0;
+ $this->nextBody();
+ }
+ }
+
+ protected function makeRangeInfo($id)
+ {
+ $range = $this->getRangeForId($id);
+ if ($range === null) {
+ if ($this->currentRangeCell) {
+ $this->currentRangeCell->getAttributes()->remove('class', 'continuing');
+ }
+ $this->currentRange = null;
+ $this->currentRangeCell = null;
+ $this->rangeRows = 0;
+ return $this::td();
+ }
+
+ if ($range === $this->currentRange) {
+ $this->growCurrentRange();
+ return null;
+ }
+ $this->startRange($range);
+
+ return $this->currentRangeCell;
+ }
+
+ protected function startRange($range)
+ {
+ $this->currentRangeCell = $this::td($this->renderRangeComment($range), [
+ 'colspan' => $this->rangeRows = 1,
+ 'class' => 'comment-cell'
+ ]);
+ if ($this->continueRange) {
+ $this->currentRangeCell->addAttributes(['class' => 'continued']);
+ $this->continueRange = false;
+ }
+ $this->currentRange = $range;
+ }
+
+ protected function renderRangeComment($range)
+ {
+ // The only purpose of this container is to avoid hovered rows from influencing
+ // the comments background color, as we're using the alpha channel to lighten it
+ // This can be replaced once we get theme-safe colors for such messages
+ return Html::tag('div', [
+ 'class' => 'range-comment-container',
+ ], Link::create($this->continueRange ? '' : $range->remark, '#', null, [
+ 'title' => $range->remark,
+ 'class' => 'range-comment'
+ ]));
+ }
+
+ protected function growCurrentRange()
+ {
+ $this->rangeRows++;
+ $this->currentRangeCell->setAttribute('rowspan', $this->rangeRows);
+ }
+
+ protected function getRangeForId($id)
+ {
+ foreach ($this->ranges as $range) {
+ if ($id >= $range->first_related_activity && $id <= $range->last_related_activity) {
+ return $range;
+ }
+ }
+
+ return null;
+ }
+
+ protected function makeLink($row)
+ {
+ $type = $row->object_type;
+ $name = $row->object_name;
+ if (substr($type, 0, 7) === 'icinga_') {
+ $type = substr($type, 7);
+ }
+
+ if (Util::hasPermission('director/showconfig')) {
+ // Later on replacing, service_set -> serviceset
+
+ // multi column key :(
+ if ($type === 'service' || $this->hasObjectFilter) {
+ $object = "\"$name\"";
+ } else {
+ $object = Link::create(
+ "\"$name\"",
+ 'director/' . str_replace('_', '', $type),
+ ['name' => $name],
+ ['title' => $this->translate('Jump to this object')]
+ );
+ }
+
+ return [
+ '[' . $row->author . ']',
+ Link::create(
+ $row->action,
+ 'director/config/activity',
+ array_merge(['id' => $row->id], $this->extraParams),
+ ['title' => $this->translate('Show details related to this change')]
+ ),
+ str_replace('_', ' ', $type),
+ $object
+ ];
+ } else {
+ return sprintf(
+ '[%s] %s %s "%s"',
+ $row->author,
+ $row->action,
+ $type,
+ $name
+ );
+ }
+ }
+
+ public function filterObject($type, $name)
+ {
+ $this->hasObjectFilter = true;
+ $this->filters[] = ['l.object_type = ?', $type];
+ $this->filters[] = ['l.object_name = ?', $name];
+
+ return $this;
+ }
+
+ public function filterHost($name)
+ {
+ $db = $this->db();
+ $filter = '%"host":' . json_encode($name) . '%';
+ $this->filters[] = ['('
+ . $db->quoteInto('l.old_properties LIKE ?', $filter)
+ . ' OR '
+ . $db->quoteInto('l.new_properties LIKE ?', $filter)
+ . ')', null];
+
+ return $this;
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'author' => 'l.author',
+ 'action' => 'l.action_name',
+ 'object_name' => 'l.object_name',
+ 'object_type' => 'l.object_type',
+ 'id' => 'l.id',
+ 'change_time' => 'l.change_time',
+ 'ts_change_time' => 'UNIX_TIMESTAMP(l.change_time)',
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $query = $this->db()->select()->from(
+ ['l' => 'director_activity_log'],
+ $this->getColumns()
+ )->order('change_time DESC')->order('id DESC')->limit(100);
+
+ foreach ($this->filters as $filter) {
+ $query->where($filter[0], $filter[1]);
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ApplyRulesTable.php b/library/Director/Web/Table/ApplyRulesTable.php
new file mode 100644
index 0000000..a861bac
--- /dev/null
+++ b/library/Director/Web/Table/ApplyRulesTable.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\DbUtil;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\IcingaConfig\AssignRenderer;
+use Icinga\Module\Director\Objects\IcingaObject;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer;
+use Ramsey\Uuid\Uuid;
+use Zend_Db_Select as ZfSelect;
+
+class ApplyRulesTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'o.object_name',
+ 'o.assign_filter',
+ ];
+
+ private $type;
+
+ /** @var IcingaObject */
+ protected $dummyObject;
+
+ protected $baseObjectUrl;
+
+ protected $linkWithName = false;
+
+ public static function create($type, Db $db)
+ {
+ $table = new static($db);
+ $table->setType($type);
+ return $table;
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function setBaseObjectUrl($url)
+ {
+ $this->baseObjectUrl = $url;
+
+ return $this;
+ }
+
+ public function createLinksWithNames($linksWithName = true)
+ {
+ $this->linkWithName = (bool) $linksWithName;
+
+ return $this;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return ['Name', 'assign where'/*, 'Actions'*/];
+ }
+
+ public function renderRow($row)
+ {
+ $row->uuid = DbUtil::binaryResult($row->uuid);
+ if ($this->linkWithName) {
+ $params = ['name' => $row->object_name];
+ } else {
+ $params = ['uuid' => Uuid::fromBytes($row->uuid)->toString()];
+ }
+ $url = Url::fromPath("director/{$this->baseObjectUrl}/edit", $params);
+
+ $assignWhere = $this->renderApplyFilter($row->assign_filter);
+
+ if (! empty($row->apply_for)) {
+ $assignWhere = sprintf('apply for %s / %s', $row->apply_for, $assignWhere);
+ }
+
+ $tr = static::tr([
+ static::td(Link::create($row->object_name, $url)),
+ static::td($assignWhere),
+ // NOT (YET) static::td($this->createActionLinks($row))->setSeparator(' ')
+ ]);
+
+ if ($row->disabled === 'y') {
+ $tr->getAttributes()->add('class', 'disabled');
+ }
+
+ return $tr;
+ }
+
+ /**
+ * Should be triggered from renderRow, still unused.
+ *
+ * @param IcingaObject $template
+ * @param string $inheritance
+ * @return $this
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ public function filterTemplate(
+ IcingaObject $template,
+ $inheritance = IcingaObjectFilterHelper::INHERIT_DIRECT
+ ) {
+ IcingaObjectFilterHelper::filterByTemplate(
+ $this->getQuery(),
+ $template,
+ 'o',
+ $inheritance
+ );
+
+ return $this;
+ }
+
+ protected function renderApplyFilter($assignFilter)
+ {
+ try {
+ $string = AssignRenderer::forFilter(
+ Filter::fromQueryString($assignFilter)
+ )->renderAssign();
+ // Do not prefix it
+ $string = preg_replace('/^assign where /', '', $string);
+ } catch (IcingaException $e) {
+ // ignore errors in filter rendering
+ $string = 'Error in Filter rendering: ' . $e->getMessage();
+ }
+
+ return $string;
+ }
+
+ public function createActionLinks($row)
+ {
+ $params = ['uuid' => Uuid::fromBytes($row->uuid)->toString()];
+ $baseUrl = 'director/' . $this->baseObjectUrl;
+ $links = [];
+ $links[] = Link::create(
+ Icon::create('sitemap'),
+ "${baseUrl}template/applytargets",
+ ['id' => $row->id],
+ ['title' => $this->translate('Show affected Objects')]
+ );
+
+ $links[] = Link::create(
+ Icon::create('edit'),
+ "$baseUrl/edit",
+ $params,
+ ['title' => $this->translate('Modify this Apply Rule')]
+ );
+
+ $links[] = Link::create(
+ Icon::create('doc-text'),
+ "$baseUrl/render",
+ $params,
+ ['title' => $this->translate('Apply Rule rendering preview')]
+ );
+
+ $links[] = Link::create(
+ Icon::create('history'),
+ "$baseUrl/history",
+ $params,
+ ['title' => $this->translate('Apply rule history')]
+ );
+
+ return $links;
+ }
+
+ protected function applyRestrictions(ZfSelect $query)
+ {
+ $auth = Auth::getInstance();
+ $type = $this->type;
+ // TODO: Centralize this logic
+ if ($type === 'scheduledDowntime') {
+ $type = 'scheduled-downtime';
+ }
+ $restrictions = $auth->getRestrictions("director/$type/apply/filter-by-name");
+ if (empty($restrictions)) {
+ return $query;
+ }
+
+ $filter = Filter::matchAny();
+ foreach ($restrictions as $restriction) {
+ $filter->addFilter(Filter::where('o.object_name', $restriction));
+ }
+
+ return FilterRenderer::applyToQuery($filter, $query);
+ }
+
+
+ /**
+ * @return IcingaObject
+ */
+ protected function getDummyObject()
+ {
+ if ($this->dummyObject === null) {
+ $type = $this->type;
+ $this->dummyObject = IcingaObject::createByType($type);
+ }
+ return $this->dummyObject;
+ }
+
+ public function prepareQuery()
+ {
+ $table = $this->getDummyObject()->getTableName();
+ $columns = [
+ 'id' => 'o.id',
+ 'uuid' => 'o.uuid',
+ 'object_name' => 'o.object_name',
+ 'disabled' => 'o.disabled',
+ 'assign_filter' => 'o.assign_filter',
+ 'apply_for' => '(NULL)',
+ ];
+
+ if ($table === 'icinga_service') {
+ $columns['apply_for'] = 'o.apply_for';
+ }
+ $query = $this->db()->select()->from(
+ ['o' => $table],
+ $columns
+ )->where(
+ "object_type = 'apply'"
+ )->order('o.object_name');
+
+ if ($this->type === 'service') {
+ $query->where('service_set_id IS NULL');
+ }
+
+ return $this->applyRestrictions($query);
+ }
+}
diff --git a/library/Director/Web/Table/BasketSnapshotTable.php b/library/Director/Web/Table/BasketSnapshotTable.php
new file mode 100644
index 0000000..08f808a
--- /dev/null
+++ b/library/Director/Web/Table/BasketSnapshotTable.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Core\Json;
+use Icinga\Module\Director\DirectorObject\Automation\Basket;
+use RuntimeException;
+
+class BasketSnapshotTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $searchColumns = [
+ 'basket_name',
+ 'summary'
+ ];
+
+ /** @var Basket */
+ protected $basket;
+
+ public function setBasket(Basket $basket)
+ {
+ $this->basket = $basket;
+ $this->searchColumns = [];
+
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ $this->splitByDay($row->ts_create_seconds);
+ $link = $this->linkToSnapshot($this->renderSummary($row->summary), $row);
+
+ if ($this->basket === null) {
+ $columns = [
+ [
+ new Link(
+ Html::tag('strong', $row->basket_name),
+ 'director/basket',
+ ['name' => $row->basket_name]
+ ),
+ Html::tag('br'),
+ $link,
+ ],
+ DateFormatter::formatTime($row->ts_create / 1000),
+ ];
+ } else {
+ $columns = [
+ $link,
+ DateFormatter::formatTime($row->ts_create / 1000),
+ ];
+ }
+ return $this::row($columns);
+ }
+
+ protected function renderSummary($summary)
+ {
+ $summary = Json::decode($summary);
+ if ($summary === null) {
+ return '-';
+ }
+ $result = [];
+ if (! is_object($summary) && ! is_array($summary)) {
+ throw new RuntimeException(sprintf(
+ 'Got invalid basket summary: %s ',
+ var_export($summary, 1)
+ ));
+ }
+
+ foreach ($summary as $type => $count) {
+ $result[] = sprintf(
+ '%dx %s',
+ $count,
+ $type
+ );
+ }
+
+ if (empty($result)) {
+ return '-';
+ }
+
+ return implode(', ', $result);
+ }
+
+ protected function linkToSnapshot($caption, $row)
+ {
+ return new Link($caption, 'director/basket/snapshot', [
+ 'checksum' => bin2hex($this->wantBinaryValue($row->content_checksum)),
+ 'ts' => $row->ts_create,
+ 'name' => $row->basket_name,
+ ]);
+ }
+
+ public function prepareQuery()
+ {
+ $query = $this->db()->select()->from([
+ 'b' => 'director_basket'
+ ], [
+ 'b.uuid',
+ 'b.basket_name',
+ 'bs.ts_create',
+ 'ts_create_seconds' => '(bs.ts_create / 1000)',
+ 'bs.content_checksum',
+ 'bc.summary',
+ ])->join(
+ ['bs' => 'director_basket_snapshot'],
+ 'bs.basket_uuid = b.uuid',
+ []
+ )->join(
+ ['bc' => 'director_basket_content'],
+ 'bc.checksum = bs.content_checksum',
+ []
+ )->order('bs.ts_create DESC');
+
+ if ($this->basket !== null) {
+ $query->where('b.uuid = ?', $this->quoteBinary($this->basket->get('uuid')));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/BasketTable.php b/library/Director/Web/Table/BasketTable.php
new file mode 100644
index 0000000..25e37e0
--- /dev/null
+++ b/library/Director/Web/Table/BasketTable.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class BasketTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'basket_name',
+ ];
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ new Link(
+ $row->basket_name,
+ 'director/basket',
+ ['name' => $row->basket_name]
+ ),
+ $row->cnt_snapshots
+ ]);
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Basket'),
+ $this->translate('Snapshots'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from([
+ 'b' => 'director_basket'
+ ], [
+ 'b.uuid',
+ 'b.basket_name',
+ 'cnt_snapshots' => 'COUNT(bs.basket_uuid)',
+ ])->joinLeft(
+ ['bs' => 'director_basket_snapshot'],
+ 'bs.basket_uuid = b.uuid',
+ []
+ )->group('b.uuid')->order('b.basket_name');
+ }
+}
diff --git a/library/Director/Web/Table/BranchActivityTable.php b/library/Director/Web/Table/BranchActivityTable.php
new file mode 100644
index 0000000..e7131ef
--- /dev/null
+++ b/library/Director/Web/Table/BranchActivityTable.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\Format\LocalTimeFormat;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\BranchActivity;
+use Icinga\Module\Director\Util;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Ramsey\Uuid\UuidInterface;
+
+class BranchActivityTable extends ZfQueryBasedTable
+{
+ protected $extraParams = [];
+
+ /** @var UuidInterface */
+ protected $branchUuid;
+
+ /** @var ?UuidInterface */
+ protected $objectUuid;
+
+ /** @var LocalTimeFormat */
+ protected $timeFormat;
+
+ protected $linkToObject = true;
+
+ public function __construct(UuidInterface $branchUuid, $db, UuidInterface $objectUuid = null)
+ {
+ $this->branchUuid = $branchUuid;
+ $this->objectUuid = $objectUuid;
+ $this->timeFormat = new LocalTimeFormat();
+ parent::__construct($db);
+ }
+
+ public function assemble()
+ {
+ $this->getAttributes()->add('class', 'activity-log');
+ }
+
+ public function renderRow($row)
+ {
+ $ts = (int) floor(BranchActivity::fixFakeTimestamp($row->timestamp_ns) / 1000000);
+ $this->splitByDay($ts);
+ $activity = BranchActivity::fromDbRow($row);
+ return $this::tr([
+ $this::td($this->makeBranchLink($activity))->setSeparator(' '),
+ $this::td($this->timeFormat->getTime($ts))
+ ])->addAttributes(['class' => ['action-' . $activity->getAction(), 'branched']]);
+ }
+
+ public function disableObjectLink()
+ {
+ $this->linkToObject = false;
+ return $this;
+ }
+
+ protected function linkObject(BranchActivity $activity)
+ {
+ if (! $this->linkToObject) {
+ return $activity->getObjectName();
+ }
+ // $type, UuidInterface $uuid
+ // Later on replacing, service_set -> serviceset
+ $type = preg_replace('/^icinga_/', '', $activity->getObjectTable());
+ return Link::create(
+ $activity->getObjectName(),
+ 'director/' . str_replace('_', '', $type),
+ ['uuid' => $activity->getObjectUuid()->toString()],
+ ['title' => $this->translate('Jump to this object')]
+ );
+ }
+
+ protected function makeBranchLink(BranchActivity $activity)
+ {
+ $type = preg_replace('/^icinga_/', '', $activity->getObjectTable());
+
+ if (Util::hasPermission('director/showconfig')) {
+ // Later on replacing, service_set -> serviceset
+ return [
+ '[' . $activity->getAuthor() . ']',
+ Link::create(
+ $activity->getAction(),
+ 'director/branch/activity',
+ array_merge(['ts' => $activity->getTimestampNs()], $this->extraParams),
+ ['title' => $this->translate('Show details related to this change')]
+ ),
+ str_replace('_', ' ', $type),
+ $this->linkObject($activity)
+ ];
+ } else {
+ return sprintf(
+ '[%s] %s %s "%s"',
+ $activity->getAuthor(),
+ $activity->getAction(),
+ $type,
+ $activity->getObjectName()
+ );
+ }
+ }
+
+ public function prepareQuery()
+ {
+ /** @var Db $connection */
+ $connection = $this->connection();
+ $query = $this->db()->select()->from(['ba' => 'director_branch_activity'], 'ba.*')
+ ->join(['b' => 'director_branch'], 'b.uuid = ba.branch_uuid', ['b.owner'])
+ ->where('branch_uuid = ?', $connection->quoteBinary($this->branchUuid->getBytes()))
+ ->order('timestamp_ns DESC');
+ if ($this->objectUuid) {
+ $query->where('ba.object_uuid = ?', $connection->quoteBinary($this->objectUuid->getBytes()));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php b/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php
new file mode 100644
index 0000000..3d5dbcb
--- /dev/null
+++ b/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Data\SimpleQueryPaginationAdapter;
+use gipfl\IcingaWeb2\Table\QueryBasedTable;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use gipfl\IcingaWeb2\Link;
+
+class BranchedIcingaCommandArgumentTable extends QueryBasedTable
+{
+ /** @var IcingaCommand */
+ protected $command;
+
+ /** @var Branch */
+ protected $branch;
+
+ protected $searchColumns = [
+ 'ca.argument_name',
+ 'ca.argument_value',
+ ];
+
+ public function __construct(IcingaCommand $command, Branch $branch)
+ {
+ $this->command = $command;
+ $this->branch = $branch;
+ $this->getAttributes()->set('data-base-target', '_self');
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create($row->argument_name, 'director/command/arguments', [
+ 'argument' => $row->argument_name,
+ 'uuid' => $this->command->getUniqueId()->toString(),
+ ]),
+ $row->argument_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Argument'),
+ $this->translate('Value'),
+ ];
+ }
+
+ protected function getPaginationAdapter()
+ {
+ return new SimpleQueryPaginationAdapter($this->getQuery());
+ }
+
+ public function getQuery()
+ {
+ return $this->prepareQuery();
+ }
+
+ protected function fetchQueryRows()
+ {
+ return $this->getQuery()->fetchAll();
+ }
+
+ protected function prepareQuery()
+ {
+ $list = [];
+ foreach ($this->command->arguments()->toPlainObject() as $name => $argument) {
+ $new = (object) [];
+ $new->argument_name = $name;
+ $new->argument_value = isset($argument->value) ? $argument->value : null;
+ $list[] = $new;
+ }
+
+ return (new ArrayDatasource($list))->select();
+ }
+}
diff --git a/library/Director/Web/Table/ChoicesTable.php b/library/Director/Web/Table/ChoicesTable.php
new file mode 100644
index 0000000..4ba2460
--- /dev/null
+++ b/library/Director/Web/Table/ChoicesTable.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class ChoicesTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = ['o.object_name'];
+
+ protected $type;
+
+ /**
+ * @param $type
+ * @param Db $db
+ * @return static
+ */
+ public static function create($type, Db $db)
+ {
+ $class = __NAMESPACE__ . '\\ChoicesTable' . ucfirst($type);
+ if (! class_exists($class)) {
+ $class = __CLASS__;
+ }
+
+ /** @var static $table */
+ $table = new $class($db);
+ $table->type = $type;
+ return $table;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Name')];
+ }
+
+ public function renderRow($row)
+ {
+ $type = $this->getType();
+ $url = Url::fromPath("director/templatechoice/${type}", [
+ 'name' => $row->object_name
+ ]);
+
+ return $this::row([
+ Link::create($row->object_name, $url)
+ ]);
+ }
+
+ protected function prepareQuery()
+ {
+ $type = $this->getType();
+ $table = "icinga_${type}_template_choice";
+ return $this->db()
+ ->select()
+ ->from(['o' => $table], 'object_name')
+ ->order('o.object_name');
+ }
+}
diff --git a/library/Director/Web/Table/ConfigFileDiffTable.php b/library/Director/Web/Table/ConfigFileDiffTable.php
new file mode 100644
index 0000000..1d14d5e
--- /dev/null
+++ b/library/Director/Web/Table/ConfigFileDiffTable.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Util;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class ConfigFileDiffTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $leftChecksum;
+
+ protected $rightChecksum;
+
+ /**
+ * @param $leftSum
+ * @param $rightSum
+ * @param Db $connection
+ * @return static
+ */
+ public static function load($leftSum, $rightSum, Db $connection)
+ {
+ $table = new static($connection);
+ $table->getAttributes()->add('class', 'config-diff');
+ return $table->setLeftChecksum($leftSum)
+ ->setRightChecksum($rightSum);
+ }
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ $this->getFileFiffLink($row),
+ $row->file_path,
+ ]);
+
+ $tr->getAttributes()->add('class', 'file-' . $row->file_action);
+ return $tr;
+ }
+
+ protected function getFileFiffLink($row)
+ {
+ $params = array('file_path' => $row->file_path);
+
+ if ($row->file_checksum_left === $row->file_checksum_right) {
+ $params['config_checksum'] = $row->config_checksum_right;
+ } elseif ($row->file_checksum_left === null) {
+ $params['config_checksum'] = $row->config_checksum_right;
+ } elseif ($row->file_checksum_right === null) {
+ $params['config_checksum'] = $row->config_checksum_left;
+ } else {
+ $params['left'] = $row->config_checksum_left;
+ $params['right'] = $row->config_checksum_right;
+ return Link::create(
+ $row->file_action,
+ 'director/config/filediff',
+ $params
+ );
+ }
+
+ return Link::create($row->file_action, 'director/config/file', $params);
+ }
+
+ public function setLeftChecksum($checksum)
+ {
+ $this->leftChecksum = $checksum;
+ return $this;
+ }
+
+ public function setRightChecksum($checksum)
+ {
+ $this->rightChecksum = $checksum;
+ return $this;
+ }
+
+ public function getTitles()
+ {
+ return array(
+ $this->translate('Action'),
+ $this->translate('File'),
+ );
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+
+ $left = $db->select()
+ ->from(
+ array('cfl' => 'director_generated_config_file'),
+ array(
+ 'file_path' => 'COALESCE(cfl.file_path, cfr.file_path)',
+ 'config_checksum_left' => $this->dbHexFunc('cfl.config_checksum'),
+ 'config_checksum_right' => $this->dbHexFunc('cfr.config_checksum'),
+ 'file_checksum_left' => $this->dbHexFunc('cfl.file_checksum'),
+ 'file_checksum_right' => $this->dbHexFunc('cfr.file_checksum'),
+ 'file_action' => '(CASE WHEN cfr.config_checksum IS NULL'
+ . " THEN 'removed' WHEN cfl.file_checksum = cfr.file_checksum"
+ . " THEN 'unmodified' ELSE 'modified' END)",
+ )
+ )->joinLeft(
+ array('cfr' => 'director_generated_config_file'),
+ $db->quoteInto(
+ 'cfl.file_path = cfr.file_path AND cfr.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->rightChecksum))
+ ),
+ array()
+ )->where(
+ 'cfl.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->leftChecksum))
+ );
+
+ $right = $db->select()
+ ->from(
+ array('cfl' => 'director_generated_config_file'),
+ array(
+ 'file_path' => 'COALESCE(cfr.file_path, cfl.file_path)',
+ 'config_checksum_left' => $this->dbHexFunc('cfl.config_checksum'),
+ 'config_checksum_right' => $this->dbHexFunc('cfr.config_checksum'),
+ 'file_checksum_left' => $this->dbHexFunc('cfl.file_checksum'),
+ 'file_checksum_right' => $this->dbHexFunc('cfr.file_checksum'),
+ 'file_action' => "('created')",
+ )
+ )->joinRight(
+ array('cfr' => 'director_generated_config_file'),
+ $db->quoteInto(
+ 'cfl.file_path = cfr.file_path AND cfl.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->leftChecksum))
+ ),
+ array()
+ )->where(
+ 'cfr.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->rightChecksum))
+ )->where('cfl.file_checksum IS NULL');
+
+ return $db->select()->union(array($left, $right))->order('file_path');
+ }
+}
diff --git a/library/Director/Web/Table/CoreApiFieldsTable.php b/library/Director/Web/Table/CoreApiFieldsTable.php
new file mode 100644
index 0000000..24a6521
--- /dev/null
+++ b/library/Director/Web/Table/CoreApiFieldsTable.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+
+class CoreApiFieldsTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = [
+ 'class' => ['common-table'/*, 'table-row-selectable'*/],
+ //'data-base-target' => '_next',
+ ];
+
+ protected $fields;
+
+ /** @var Url */
+ protected $url;
+
+ public function __construct($fields, Url $url)
+ {
+ $this->url = $url;
+ $this->fields = $fields;
+ }
+
+ public function assemble()
+ {
+ if (empty($this->fields)) {
+ return;
+ }
+ $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th'))));
+ foreach ($this->fields as $name => $field) {
+ $tr = $this::tr([
+ $this::td($name),
+ $this::td(Link::create(
+ $field->type,
+ $this->url->with('type', $field->type)
+ )),
+ $this::td($field->id)
+ // $this::td($field->array_rank),
+ // $this::td($this->renderKeyValue($field->attributes))
+ ]);
+ $this->addAttributeColumns($tr, $field->attributes);
+ $this->add($tr);
+ }
+ }
+
+ protected function addAttributeColumns(BaseHtmlElement $tr, $attrs)
+ {
+ $tr->add([
+ $this->makeBooleanColumn($attrs->state),
+ $this->makeBooleanColumn($attrs->config),
+ $this->makeBooleanColumn($attrs->required),
+ $this->makeBooleanColumn(isset($attrs->deprecated) ? $attrs->deprecated : null),
+ $this->makeBooleanColumn($attrs->no_user_modify),
+ $this->makeBooleanColumn($attrs->no_user_view),
+ $this->makeBooleanColumn($attrs->navigation),
+ ]);
+ }
+
+ protected function makeBooleanColumn($value)
+ {
+ if ($value === null) {
+ return $this::td('-');
+ }
+
+ return $this::td($value ? Html::tag('strong', 'true') : 'false');
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Name'),
+ $this->translate('Type'),
+ $this->translate('Id'),
+ // $this->translate('Array Rank'),
+ // $this->translate('Attributes')
+ $this->translate('State'),
+ $this->translate('Config'),
+ $this->translate('Required'),
+ $this->translate('Deprecated'),
+ $this->translate('Protected'),
+ $this->translate('Hidden'),
+ $this->translate('Nav'),
+ ];
+ }
+
+ protected function renderKeyValue($values)
+ {
+ $parts = [];
+ foreach ((array) $values as $key => $value) {
+ if (is_bool($value)) {
+ $value = $value ? 'true' : 'false';
+ }
+ $parts[] = "$key: $value";
+ }
+
+ return implode(', ', $parts);
+ }
+}
diff --git a/library/Director/Web/Table/CoreApiObjectsTable.php b/library/Director/Web/Table/CoreApiObjectsTable.php
new file mode 100644
index 0000000..c2cefea
--- /dev/null
+++ b/library/Director/Web/Table/CoreApiObjectsTable.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Html;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+
+class CoreApiObjectsTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = [
+ 'class' => ['common-table', 'table-row-selectable'],
+ 'data-base-target' => '_next',
+ ];
+
+ /** @var IcingaEndpoint */
+ protected $endpoint;
+
+ protected $objects;
+
+ protected $type;
+
+ public function __construct($objects, IcingaEndpoint $endpoint, $type)
+ {
+ $this->objects = $objects;
+ $this->endpoint = $endpoint;
+ $this->type = $type;
+ }
+
+ public function assemble()
+ {
+ if (empty($this->objects)) {
+ return;
+ }
+ $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th'))));
+ foreach ($this->objects as $name) {
+ $this->add($this::tr($this::td(Link::create(
+ str_replace('!', ': ', $name),
+ 'director/inspect/object',
+ [
+ 'name' => $name,
+ 'type' => $this->type->name,
+ 'plural' => $this->type->plural_name,
+ 'endpoint' => $this->endpoint->getObjectName()
+ ]
+ ))));
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Name'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/CoreApiPrototypesTable.php b/library/Director/Web/Table/CoreApiPrototypesTable.php
new file mode 100644
index 0000000..78fd964
--- /dev/null
+++ b/library/Director/Web/Table/CoreApiPrototypesTable.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+
+class CoreApiPrototypesTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = ['class' => ['common-table']];
+
+ protected $prototypes;
+
+ protected $typeName;
+
+ public function __construct($prototypes, $typeName)
+ {
+ $this->prototypes = $prototypes;
+ $this->typeName = $typeName;
+ }
+
+ public function assemble()
+ {
+ if (empty($this->prototypes)) {
+ return;
+ }
+ $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th'))));
+ $type = $this->typeName;
+ foreach ($this->prototypes as $name) {
+ $this->add($this::tr($this::td("$type.$name()")));
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Name'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/CustomvarTable.php b/library/Director/Web/Table/CustomvarTable.php
new file mode 100644
index 0000000..f9a3844
--- /dev/null
+++ b/library/Director/Web/Table/CustomvarTable.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Zend_Db_Adapter_Abstract as ZfDbAdapter;
+use Zend_Db_Select as ZfDbSelect;
+
+class CustomvarTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = array(
+ 'varname',
+ );
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ new Link(
+ $row->varname,
+ 'director/customvar/variants',
+ ['name' => $row->varname]
+ )
+ ]);
+
+ foreach ($this->getObjectTypes() as $type) {
+ $tr->add($this::td(Html::tag('nobr', null, sprintf(
+ $this->translate('%d / %d'),
+ $row->{"cnt_$type"},
+ $row->{"distinct_$type"}
+ ))));
+ }
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ $this->translate('Variable name'),
+ $this->translate('Distinct Commands'),
+ $this->translate('Hosts'),
+ $this->translate('Services'),
+ $this->translate('Service Sets'),
+ $this->translate('Notifications'),
+ $this->translate('Users'),
+ );
+ }
+
+ protected function getObjectTypes()
+ {
+ return ['command', 'host', 'service', 'service_set', 'notification', 'user'];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $varsColumns = ['varname' => 'v.varname'];
+ $varsTypes = $this->getObjectTypes();
+ foreach ($varsTypes as $type) {
+ $varsColumns["cnt_$type"] = '(0)';
+ $varsColumns["distinct_$type"] = '(0)';
+ }
+ $varsQueries = [];
+ foreach ($varsTypes as $type) {
+ $varsQueries[] = $this->makeVarSub($type, $varsColumns, $db);
+ }
+
+ $union = $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL);
+
+ $columns = ['varname' => 'u.varname'];
+ foreach ($varsTypes as $column) {
+ $columns["cnt_$column"] = "SUM(u.cnt_$column)";
+ $columns["distinct_$column"] = "SUM(u.distinct_$column)";
+ }
+ return $db->select()->from(
+ array('u' => $union),
+ $columns
+ )->group('u.varname')->order('u.varname ASC')->limit(100);
+ }
+
+ /**
+ * @param string $type
+ * @param array $columns
+ * @param ZfDbAdapter $db
+ * @return ZfDbSelect
+ */
+ protected function makeVarSub($type, array $columns, ZfDbAdapter $db)
+ {
+ $columns["cnt_$type"] = 'COUNT(*)';
+ $columns["distinct_$type"] = 'COUNT(DISTINCT varvalue)';
+ return $db->select()->from(
+ ['v' => "icinga_${type}_var"],
+ $columns
+ )->join(
+ ['o' => "icinga_${type}"],
+ "o.id = v.${type}_id",
+ []
+ )->where('o.object_type != ?', 'external_object')->group('varname');
+ }
+}
diff --git a/library/Director/Web/Table/CustomvarVariantsTable.php b/library/Director/Web/Table/CustomvarVariantsTable.php
new file mode 100644
index 0000000..80fca70
--- /dev/null
+++ b/library/Director/Web/Table/CustomvarVariantsTable.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\PlainObjectRenderer;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Zend_Db_Adapter_Abstract as ZfDbAdapter;
+use Zend_Db_Select as ZfDbSelect;
+
+class CustomvarVariantsTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = ['varvalue'];
+
+ protected $varName;
+
+ public static function create(Db $db, $varName)
+ {
+ $table = new static($db);
+ $table->varName = $varName;
+ $table->getAttributes()->set('class', 'common-table');
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ if ($row->format === 'json') {
+ $value = PlainObjectRenderer::render(json_decode($row->varvalue));
+ } else {
+ $value = $row->varvalue;
+ }
+ $tr = $this::row([
+ /* new Link(
+ $value,
+ 'director/customvar/value',
+ ['name' => $row->varvalue]
+ )*/
+ $value
+ ]);
+
+ foreach ($this->getObjectTypes() as $type) {
+ $cnt = (int) $row->{"cnt_$type"};
+ if ($cnt === 0) {
+ $cnt = '-';
+ }
+ $tr->add($this::td($cnt));
+ }
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ $this->translate('Variable Value'),
+ $this->translate('Commands'),
+ $this->translate('Hosts'),
+ $this->translate('Services'),
+ $this->translate('Service Sets'),
+ $this->translate('Notifications'),
+ $this->translate('Users'),
+ );
+ }
+
+ protected function getObjectTypes()
+ {
+ return ['command', 'host', 'service', 'service_set', 'notification', 'user'];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $varsColumns = ['varvalue' => 'v.varvalue'];
+ $varsTypes = $this->getObjectTypes();
+ foreach ($varsTypes as $type) {
+ $varsColumns["cnt_$type"] = '(0)';
+ }
+ $varsQueries = [];
+ foreach ($varsTypes as $type) {
+ $varsQueries[] = $this->makeVarSub($type, $varsColumns, $db);
+ }
+
+ $union = $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL);
+
+ $columns = [
+ 'varvalue' => 'u.varvalue',
+ 'format' => 'u.format',
+ ];
+ foreach ($varsTypes as $column) {
+ $columns["cnt_$column"] = "SUM(u.cnt_$column)";
+ }
+ return $db->select()->from(['u' => $union], $columns)
+ ->group('u.varvalue')->group('u.format')
+ ->order('u.varvalue ASC')
+ ->order('u.format ASC')
+ ->limit(100);
+ }
+
+ /**
+ * @param string $type
+ * @param array $columns
+ * @param ZfDbAdapter $db
+ * @return ZfDbSelect
+ */
+ protected function makeVarSub($type, array $columns, ZfDbAdapter $db)
+ {
+ $columns["cnt_$type"] = 'COUNT(*)';
+ $columns['format'] = 'v.format';
+ return $db->select()->from(
+ ['v' => "icinga_${type}_var"],
+ $columns
+ )->join(
+ ['o' => "icinga_${type}"],
+ "o.id = v.${type}_id",
+ []
+ )->where(
+ 'v.varname = ?',
+ $this->varName
+ )->where(
+ 'o.object_type != ?',
+ 'external_object'
+ )->group('varvalue')->group('v.format');
+ }
+}
diff --git a/library/Director/Web/Table/DatafieldCategoryTable.php b/library/Director/Web/Table/DatafieldCategoryTable.php
new file mode 100644
index 0000000..6f07939
--- /dev/null
+++ b/library/Director/Web/Table/DatafieldCategoryTable.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use ipl\Html\Html;
+
+class DatafieldCategoryTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'dfc.category_name',
+ 'dfc.description',
+ ];
+
+ public function getColumns()
+ {
+ return array(
+ 'id' => 'dfc.id',
+ 'category_name' => 'dfc.category_name',
+ 'description' => 'dfc.description',
+ 'assigned_fields' => 'COUNT(df.id)',
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $main = [Link::create(
+ $row->category_name,
+ 'director/datafieldcategory/edit',
+ ['name' => $row->category_name]
+ )];
+
+ if ($row->description !== null && strlen($row->description)) {
+ $main[] = Html::tag('br');
+ $main[] = Html::tag('small', $row->description);
+ }
+ return $this::tr([
+ $this::td($main),
+ $this::td($row->assigned_fields)
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Category Name'),
+ $this->translate('# Used'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ return $db->select()->from(
+ ['dfc' => 'director_datafield_category'],
+ $this->getColumns()
+ )->joinLeft(
+ ['df' => 'director_datafield'],
+ 'df.category_id = dfc.id',
+ []
+ )->group('dfc.id')->group('dfc.category_name')->order('category_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/DatafieldTable.php b/library/Director/Web/Table/DatafieldTable.php
new file mode 100644
index 0000000..4b321d7
--- /dev/null
+++ b/library/Director/Web/Table/DatafieldTable.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Zend_Db_Adapter_Abstract as ZfDbAdapter;
+use Zend_Db_Select as ZfDbSelect;
+
+class DatafieldTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'df.varname',
+ 'df.caption',
+ ];
+
+ public function getColumns()
+ {
+ return [
+ 'id' => 'df.id',
+ 'varname' => 'df.varname',
+ 'caption' => 'df.caption',
+ 'description' => 'df.description',
+ 'datatype' => 'df.datatype',
+ 'category' => 'dfc.category_name',
+ 'assigned_fields' => 'SUM(used_fields.cnt)',
+ 'assigned_vars' => 'SUM(used_vars.cnt)',
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::tr([
+ $this::td(Link::create(
+ $row->caption,
+ 'director/datafield/edit',
+ ['id' => $row->id]
+ )),
+ $this::td($row->varname),
+ $this::td($row->category),
+ $this::td($row->assigned_fields),
+ $this::td($row->assigned_vars)
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Label'),
+ $this->translate('Field name'),
+ $this->translate('Category'),
+ $this->translate('# Used'),
+ $this->translate('# Vars'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $fieldTypes = ['command', 'host', 'notification', 'service', 'user'];
+ $varsTypes = ['command', 'host', 'notification', 'service', 'service_set', 'user'];
+
+ $fieldsQueries = [];
+ foreach ($fieldTypes as $type) {
+ $fieldsQueries[] = $this->makeDatafieldSub($type, $db);
+ }
+
+ $varsQueries = [];
+ foreach ($varsTypes as $type) {
+ $varsQueries[] = $this->makeVarSub($type, $db);
+ }
+
+ return $db->select()->from(
+ ['df' => 'director_datafield'],
+ $this->getColumns()
+ )->joinLeft(
+ ['dfc' => 'director_datafield_category'],
+ 'df.category_id = dfc.id',
+ []
+ )->joinLeft(
+ ['used_fields' => $db->select()->union($fieldsQueries, ZfDbSelect::SQL_UNION_ALL)],
+ 'used_fields.datafield_id = df.id',
+ []
+ )->joinLeft(
+ ['used_vars' => $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL)],
+ 'used_vars.varname = df.varname',
+ []
+ )->group('df.id')->group('df.varname')->group('dfc.category_name')->order('caption ASC');
+ }
+
+ /**
+ * @param $type
+ * @param ZfDbAdapter $db
+ *
+ * @return ZfDbSelect
+ */
+ protected function makeDatafieldSub($type, ZfDbAdapter $db)
+ {
+ return $db->select()->from("icinga_${type}_field", [
+ 'cnt' => 'COUNT(*)',
+ 'datafield_id'
+ ])->group('datafield_id');
+ }
+
+ /**
+ * @param $type
+ * @param ZfDbAdapter $db
+ *
+ * @return ZfDbSelect
+ */
+ protected function makeVarSub($type, ZfDbAdapter $db)
+ {
+ return $db->select()->from("icinga_${type}_var", [
+ 'cnt' => 'COUNT(*)',
+ 'varname'
+ ])->group('varname');
+ }
+}
diff --git a/library/Director/Web/Table/DatalistEntryTable.php b/library/Director/Web/Table/DatalistEntryTable.php
new file mode 100644
index 0000000..70167c7
--- /dev/null
+++ b/library/Director/Web/Table/DatalistEntryTable.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\DirectorDatalist;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class DatalistEntryTable extends ZfQueryBasedTable
+{
+ protected $datalist;
+
+ protected $searchColumns = [
+ 'entry_name',
+ 'entry_value'
+ ];
+
+ public function setList(DirectorDatalist $list)
+ {
+ $this->datalist = $list;
+
+ return $this;
+ }
+
+ public function getList()
+ {
+ return $this->datalist;
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'list_name' => 'l.list_name',
+ 'list_id' => 'le.list_id',
+ 'entry_name' => 'le.entry_name',
+ 'entry_value' => 'le.entry_value',
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::tr([
+ $this::td(Link::create($row->entry_name, 'director/data/listentry/edit', [
+ 'list' => $row->list_name,
+ 'entry_name' => $row->entry_name,
+ ])),
+ $this::td($row->entry_value)
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ 'entry_name' => $this->translate('Key'),
+ 'entry_value' => $this->translate('Label'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['le' => 'director_datalist_entry'],
+ $this->getColumns()
+ )->join(
+ ['l' => 'director_datalist'],
+ 'l.id = le.list_id',
+ []
+ )->where(
+ 'le.list_id = ?',
+ $this->getList()->id
+ )->order('le.entry_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/DatalistTable.php b/library/Director/Web/Table/DatalistTable.php
new file mode 100644
index 0000000..7b35fe0
--- /dev/null
+++ b/library/Director/Web/Table/DatalistTable.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class DatalistTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = ['list_name'];
+
+ public function getColumns()
+ {
+ return [
+ 'id' => 'l.id',
+ 'list_name' => 'l.list_name',
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::tr($this::td(Link::create(
+ $row->list_name,
+ 'director/data/listentry',
+ array('list' => $row->list_name)
+ )));
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('List name')];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['l' => 'director_datalist'],
+ $this->getColumns()
+ )->order('list_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/DbHelper.php b/library/Director/Web/Table/DbHelper.php
new file mode 100644
index 0000000..573f946
--- /dev/null
+++ b/library/Director/Web/Table/DbHelper.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Expr as Expr;
+
+trait DbHelper
+{
+ public function dbHexFunc($column)
+ {
+ if ($this->isPgsql()) {
+ return sprintf("LOWER(ENCODE(%s, 'hex'))", $column);
+ } else {
+ return sprintf("LOWER(HEX(%s))", $column);
+ }
+ }
+
+ public function quoteBinary($binary)
+ {
+ if ($binary === '') {
+ return '';
+ }
+
+ if (is_array($binary)) {
+ return array_map([$this, 'quoteBinary'], $binary);
+ }
+
+ if ($this->isPgsql()) {
+ return new Expr("'\\x" . bin2hex($binary) . "'");
+ }
+
+ return new Expr('0x' . bin2hex($binary));
+ }
+
+ public function isPgsql()
+ {
+ return $this->db() instanceof \Zend_Db_Adapter_Pdo_Pgsql;
+ }
+
+ public function isMysql()
+ {
+ return $this->db() instanceof \Zend_Db_Adapter_Pdo_Mysql;
+ }
+
+ public function wantBinaryValue($value)
+ {
+ if (is_resource($value)) {
+ return stream_get_contents($value);
+ }
+
+ return $value;
+ }
+
+ public function getChecksum($checksum)
+ {
+ return bin2hex($this->wantBinaryValue($checksum));
+ }
+
+ public function getShortChecksum($checksum)
+ {
+ if ($checksum === null) {
+ return null;
+ }
+
+ return substr($this->getChecksum($checksum), 0, 7);
+ }
+}
diff --git a/library/Director/Web/Table/Dependency/DependencyInfoTable.php b/library/Director/Web/Table/Dependency/DependencyInfoTable.php
new file mode 100644
index 0000000..28aa856
--- /dev/null
+++ b/library/Director/Web/Table/Dependency/DependencyInfoTable.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table\Dependency;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Director\Application\DependencyChecker;
+use Icinga\Web\Url;
+
+class DependencyInfoTable
+{
+ protected $module;
+
+ protected $checker;
+
+ public function __construct(DependencyChecker $checker, Module $module)
+ {
+ $this->module = $module;
+ $this->checker = $checker;
+ }
+
+ protected function linkToModule($name, $icon)
+ {
+ return Html::link(
+ Html::escape($name),
+ Html::webUrl('config/module', ['name' => $name]),
+ [
+ 'class' => "icon-$icon"
+ ]
+ );
+ }
+
+ public function render()
+ {
+ $html = '<table class="common-table table-row-selectable">
+<thead>
+<tr>
+ <th>' . Html::escape($this->translate('Module name')) . '</th>
+ <th>' . Html::escape($this->translate('Required')) . '</th>
+ <th>' . Html::escape($this->translate('Installed')) . '</th>
+</tr>
+</thead>
+<tbody data-base-target="_next">
+';
+ foreach ($this->checker->getDependencies($this->module) as $dependency) {
+ $name = $dependency->getName();
+ $isLibrary = substr($name, 0, 11) === 'icinga-php-';
+ $rowAttributes = $isLibrary ? ['data-base-target' => '_self'] : null;
+ if ($dependency->isSatisfied()) {
+ if ($dependency->isSatisfied()) {
+ $icon = 'ok';
+ } else {
+ $icon = 'cancel';
+ }
+ $link = $isLibrary ? $this->noLink($name, $icon) : $this->linkToModule($name, $icon);
+ $installed = $dependency->getInstalledVersion();
+ } elseif ($dependency->isInstalled()) {
+ $installed = sprintf('%s (%s)', $dependency->getInstalledVersion(), $this->translate('disabled'));
+ $link = $this->linkToModule($name, 'cancel');
+ } else {
+ $installed = $this->translate('missing');
+ $repository = $isLibrary ? $name : "icingaweb2-module-$name";
+ $link = sprintf(
+ '%s (%s)',
+ $this->noLink($name, 'cancel'),
+ Html::linkToGitHub(Html::escape($this->translate('more')), 'Icinga', $repository)
+ );
+ }
+
+ $html .= $this->htmlRow([
+ $link,
+ Html::escape($dependency->getRequirement()),
+ Html::escape($installed)
+ ], $rowAttributes);
+ }
+
+ return $html . '</tbody>
+</table>
+';
+ }
+
+ protected function noLink($label, $icon)
+ {
+ return Html::link(Html::escape($label), Url::fromRequest()->with('rnd', rand(1, 100000)), [
+ 'class' => "icon-$icon"
+ ]);
+ }
+
+ protected function translate($string)
+ {
+ return \mt('director', $string);
+ }
+
+ protected function htmlRow(array $cols, $rowAttributes)
+ {
+ $content = '';
+ foreach ($cols as $escapedContent) {
+ $content .= Html::tag('td', null, $escapedContent);
+ }
+ return Html::tag('tr', $rowAttributes, $content);
+ }
+}
diff --git a/library/Director/Web/Table/Dependency/Html.php b/library/Director/Web/Table/Dependency/Html.php
new file mode 100644
index 0000000..092f799
--- /dev/null
+++ b/library/Director/Web/Table/Dependency/Html.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table\Dependency;
+
+use Icinga\Web\Url;
+use InvalidArgumentException;
+
+/**
+ * Minimal HTML helper, as we might be forced to run without ipl
+ */
+class Html
+{
+ public static function tag($tag, $attributes = [], $escapedContent = null)
+ {
+ $result = "<$tag";
+ if (! empty($attributes)) {
+ foreach ($attributes as $name => $value) {
+ if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) {
+ throw new InvalidArgumentException("Invalid attribute name: '$name'");
+ }
+
+ $result .= " $name=\"" . self::escapeAttributeValue($value) . '"';
+ }
+ }
+
+ return "$result>$escapedContent</$tag>";
+ }
+
+ public static function webUrl($path, $params)
+ {
+ return Url::fromPath($path, $params);
+ }
+
+ public static function link($escapedLabel, $url, $attributes = [])
+ {
+ return static::tag('a', [
+ 'href' => $url,
+ ] + $attributes, $escapedLabel);
+ }
+
+ public static function linkToGitHub($escapedLabel, $namespace, $repository)
+ {
+ return static::link(
+ $escapedLabel,
+ 'https://github.com/' . urlencode($namespace) . '/' . urlencode($repository),
+ [
+ 'target' => '_blank',
+ 'rel' => 'noreferrer',
+ 'class' => 'icon-forward'
+ ]
+ );
+ }
+
+ protected static function escapeAttributeValue($value)
+ {
+ $value = str_replace('"', '&quot;', $value);
+ // Escape ambiguous ampersands
+ return preg_replace_callback('/&[0-9A-Z]+;/i', function ($match) {
+ $subject = $match[0];
+
+ if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) {
+ // Ambiguous ampersand
+ return str_replace('&', '&amp;', $subject);
+ }
+
+ return $subject;
+ }, $value);
+ }
+
+ public static function escape($any)
+ {
+ return htmlspecialchars($any);
+ }
+}
diff --git a/library/Director/Web/Table/DependencyTemplateUsageTable.php b/library/Director/Web/Table/DependencyTemplateUsageTable.php
new file mode 100644
index 0000000..d7537c5
--- /dev/null
+++ b/library/Director/Web/Table/DependencyTemplateUsageTable.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class DependencyTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'applyrules' => $this->translate('Apply Rules'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'applyrules' => $this->getSummaryLine('apply'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/DeploymentLogTable.php b/library/Director/Web/Table/DeploymentLogTable.php
new file mode 100644
index 0000000..2d5cb94
--- /dev/null
+++ b/library/Director/Web/Table/DeploymentLogTable.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Date\DateFormatter;
+
+class DeploymentLogTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $activeStageName;
+
+ public function setActiveStageName($name)
+ {
+ $this->activeStageName = $name;
+ return $this;
+ }
+
+ public function assemble()
+ {
+ $this->getAttributes()->add('class', 'deployment-log');
+ }
+
+ public function renderRow($row)
+ {
+ $this->splitByDay($row->start_time);
+
+ $shortSum = $this->getShortChecksum($row->config_checksum);
+ $tr = $this::tr([
+ $this::td(Link::create(
+ $shortSum === null ? $row->peer_identity : [$row->peer_identity, " ($shortSum)"],
+ 'director/deployment',
+ ['id' => $row->id]
+ )),
+ $this::td(DateFormatter::formatTime($row->start_time))
+ ])->addAttributes(['class' => $this->getMyRowClasses($row)]);
+
+ return $tr;
+ }
+
+ protected function getMyRowClasses($row)
+ {
+ if ($row->startup_succeeded === 'y') {
+ $classes = ['succeeded'];
+ } elseif ($row->startup_succeeded === 'n') {
+ $classes = ['failed'];
+ } elseif ($row->stage_collected === null) {
+ $classes = ['pending'];
+ } elseif ($row->dump_succeeded === 'y') {
+ $classes = ['sent'];
+ } else {
+ // TODO: does this ever be stored?
+ $classes = ['notsent'];
+ }
+
+ if ($this->activeStageName !== null
+ && $row->stage_name === $this->activeStageName
+ ) {
+ $classes[] = 'running';
+ }
+
+ return $classes;
+ }
+
+ public function getColumns()
+ {
+ $columns = [
+ 'id' => 'l.id',
+ 'peer_identity' => 'l.peer_identity',
+ 'start_time' => 'UNIX_TIMESTAMP(l.start_time)',
+ 'stage_collected' => 'l.stage_collected',
+ 'dump_succeeded' => 'l.dump_succeeded',
+ 'stage_name' => 'l.stage_name',
+ 'startup_succeeded' => 'l.startup_succeeded',
+ 'config_checksum' => 'l.config_checksum',
+ ];
+
+ return $columns;
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ array('l' => 'director_deployment_log'),
+ $this->getColumns()
+ )->order('l.start_time DESC')->limit(100);
+ }
+}
diff --git a/library/Director/Web/Table/FilterableByUsage.php b/library/Director/Web/Table/FilterableByUsage.php
new file mode 100644
index 0000000..5e8695f
--- /dev/null
+++ b/library/Director/Web/Table/FilterableByUsage.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+interface FilterableByUsage
+{
+ public function showOnlyUsed();
+
+ public function showOnlyUnUsed();
+}
diff --git a/library/Director/Web/Table/GeneratedConfigFileTable.php b/library/Director/Web/Table/GeneratedConfigFileTable.php
new file mode 100644
index 0000000..97f7091
--- /dev/null
+++ b/library/Director/Web/Table/GeneratedConfigFileTable.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class GeneratedConfigFileTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $searchColumns = ['file_path'];
+
+ protected $deploymentId;
+
+ protected $activeFile;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ public static function load(IcingaConfig $config, Db $db)
+ {
+ $table = new static($db);
+ $table->config = $config;
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ $counts = implode(' / ', [
+ $row->cnt_object,
+ $row->cnt_template,
+ $row->cnt_apply
+ ]);
+
+ $tr = $this::row([
+ $this->getFileLink($row),
+ $counts,
+ $row->size
+ ]);
+
+ if ($row->file_path === $this->activeFile) {
+ $tr->getAttributes()->add('class', 'active');
+ }
+
+ return $tr;
+ }
+
+ public function setActiveFilename($filename)
+ {
+ $this->activeFile = $filename;
+ return $this;
+ }
+
+ protected function getFileLink($row)
+ {
+ $params = [
+ 'config_checksum' => $row->config_checksum,
+ 'file_path' => $row->file_path
+ ];
+
+ if ($this->deploymentId) {
+ $params['deployment_id'] = $this->deploymentId;
+ }
+
+ return Link::create($row->file_path, 'director/config/file', $params);
+ }
+
+ public function setDeploymentId($id)
+ {
+ if ($id) {
+ $this->deploymentId = (int) $id;
+ }
+
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('File'),
+ $this->translate('Object/Tpl/Apply'),
+ $this->translate('Size'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $columns = [
+ 'file_path' => 'cf.file_path',
+ 'size' => 'LENGTH(f.content)',
+ 'cnt_object' => 'f.cnt_object',
+ 'cnt_template' => 'f.cnt_template',
+ 'cnt_apply' => 'f.cnt_apply',
+ 'cnt_all' => "f.cnt_object || ' / ' || f.cnt_template || ' / ' || f.cnt_apply",
+ 'checksum' => 'LOWER(HEX(f.checksum))',
+ 'config_checksum' => 'LOWER(HEX(cf.config_checksum))',
+ ];
+
+ if ($this->isPgsql()) {
+ $columns['checksum'] = "LOWER(ENCODE(f.checksum, 'hex'))";
+ $columns['config_checksum'] = "LOWER(ENCODE(cf.config_checksum, 'hex'))";
+ }
+
+ return $this->db()->select()->from(
+ ['cf' => 'director_generated_config_file'],
+ $columns
+ )->join(
+ ['f' => 'director_generated_file'],
+ 'cf.file_checksum = f.checksum',
+ []
+ )->where(
+ 'config_checksum = ?',
+ $this->quoteBinary($this->config->getChecksum())
+ )->order('cf.file_path ASC');
+ }
+}
diff --git a/library/Director/Web/Table/GroupMemberTable.php b/library/Director/Web/Table/GroupMemberTable.php
new file mode 100644
index 0000000..b0814ad
--- /dev/null
+++ b/library/Director/Web/Table/GroupMemberTable.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\AssignRenderer;
+use Icinga\Module\Director\Objects\IcingaObjectGroup;
+use Exception;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class GroupMemberTable extends ZfQueryBasedTable
+{
+ use MultiSelect;
+
+ protected $searchColumns = [
+ 'o.object_name',
+ // membership_type
+ ];
+
+ protected $type;
+
+ /** @var IcingaObjectGroup */
+ protected $group;
+
+ /**
+ * @param $type
+ * @param Db $db
+ * @return static
+ */
+ public static function create($type, Db $db)
+ {
+ $class = __NAMESPACE__ . '\\GroupMemberTable' . ucfirst($type);
+ if (! class_exists($class)) {
+ $class = __CLASS__;
+ }
+
+ /** @var static $table */
+ $table = new $class($db);
+ $table->type = $type;
+ return $table;
+ }
+ public function assemble()
+ {
+ if ($this->type === 'host') {
+ $this->enableMultiSelect(
+ 'director/hosts/edit',
+ 'director/hosts',
+ ['name']
+ );
+ }
+ }
+
+ public function setGroup(IcingaObjectGroup $group)
+ {
+ $this->group = $group;
+ return $this;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ if ($this->group === null) {
+ return [
+ $this->translate('Group'),
+ $this->translate('Member'),
+ $this->translate('via')
+ ];
+ } else {
+ return [
+ $this->translate('Member'),
+ $this->translate('via')
+ ];
+ }
+ }
+
+ public function renderRow($row)
+ {
+ $type = $this->getType();
+ if ($row->object_type === 'apply') {
+ $params = [
+ 'id' => $row->id
+ ];
+ } elseif (isset($row->host_id)) {
+ // I would prefer to see host=<name> and set=<name>, but joining
+ // them here is pointless. We should use DeferredHtml for these,
+ // remember hosts/sets we need and fetch them in a single query at
+ // rendering time. For now, this works fine - just... the URLs are
+ // not so nice
+ $params = [
+ 'name' => $row->object_name,
+ 'host_id' => $row->host_id
+ ];
+ } elseif (isset($row->service_set_id)) {
+ $params = [
+ 'name' => $row->object_name,
+ 'set_id' => $row->service_set_id
+ ];
+ } else {
+ $params = [
+ 'name' => $row->object_name
+ ];
+ }
+
+ $url = Url::fromPath("director/${type}", $params);
+
+ $tr = $this::tr();
+
+ if ($this->group === null) {
+ $tr->add($this::td($row->group_name));
+ }
+ $link = Link::create($row->object_name, $url);
+ if ($row->object_type === 'apply') {
+ $link = [
+ $link,
+ ' (where ',
+ $this->renderApplyFilter($row->assign_filter),
+ ')'
+ ];
+ }
+
+ $tr->add([
+ $this::td($link),
+ $this::td($row->membership_type)
+ ]);
+
+ return $tr;
+ }
+
+ protected function renderApplyFilter($assignFilter)
+ {
+ try {
+ $string = AssignRenderer::forFilter(
+ Filter::fromQueryString($assignFilter)
+ )->renderAssign();
+ // Do not prefix it
+ $string = preg_replace('/^assign where /', '', $string);
+ } catch (Exception $e) {
+ // ignore errors in filter rendering
+ $string = 'Error in Filter rendering: ' . $e->getMessage();
+ }
+
+ return $string;
+ }
+
+ protected function prepareQuery()
+ {
+ // select h.object_name, hg.object_name,
+ // CASE WHEN hgh.host_id IS NULL THEN 'apply' ELSE 'direct' END AS assi
+ // from icinga_hostgroup_host_resolved hgr join icinga_host h on h.id = hgr.host_id
+ // join icinga_hostgroup hg on hgr.hostgroup_id = hg.id
+ // left join icinga_hostgroup_host hgh on hgh.host_id = h.id and hgh.hostgroup_id = hg.id;
+
+ $type = $this->getType();
+ $columns = [
+ 'o.id',
+ 'o.object_type',
+ 'o.object_name',
+ 'membership_type' => "CASE WHEN go.${type}_id IS NULL THEN 'apply' ELSE 'direct' END"
+ ];
+
+ if ($this->group === null) {
+ $columns = ['group_name' => 'g.object_name'] + $columns;
+ }
+ if ($type === 'service') {
+ $columns[] = 'o.assign_filter';
+ $columns[] = 'o.host_id';
+ $columns[] = 'o.service_set_id';
+ }
+
+ $query = $this->db()->select()->from(
+ ['gro' => "icinga_${type}group_${type}_resolved"],
+ $columns
+ )->join(
+ ['o' => "icinga_${type}"],
+ "o.id = gro.${type}_id",
+ []
+ )->join(
+ ['g' => "icinga_${type}group"],
+ "gro.${type}group_id = g.id",
+ []
+ )->joinLeft(
+ ['go' => "icinga_${type}group_${type}"],
+ "go.${type}_id = o.id AND go.${type}group_id = g.id",
+ []
+ )->order('o.object_name');
+
+ if ($this->group !== null) {
+ $query->where('g.id = ?', $this->group->get('id'));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/HostTemplateUsageTable.php b/library/Director/Web/Table/HostTemplateUsageTable.php
new file mode 100644
index 0000000..2d1ee2f
--- /dev/null
+++ b/library/Director/Web/Table/HostTemplateUsageTable.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class HostTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'objects' => $this->translate('Objects'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'objects' => $this->getSummaryLine('object'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/IcingaAppliedServiceTable.php b/library/Director/Web/Table/IcingaAppliedServiceTable.php
new file mode 100644
index 0000000..b669296
--- /dev/null
+++ b/library/Director/Web/Table/IcingaAppliedServiceTable.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaService;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaAppliedServiceTable extends ZfQueryBasedTable
+{
+ protected $service;
+
+ protected $searchColumns = array(
+ 'service',
+ );
+
+ public function setService(IcingaService $service)
+ {
+ $this->service = $service;
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ new Link($row->service, 'director/service', ['id' => $row->id])
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Servicename')];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ array('s' => 'icinga_service'),
+ array()
+ )->joinLeft(
+ array('si' => 'icinga_service_inheritance'),
+ 's.id = si.service_id',
+ array()
+ )->where(
+ 'si.parent_service_id = ?',
+ $this->service->id
+ )->where('s.object_type = ?', 'apply');
+ }
+}
diff --git a/library/Director/Web/Table/IcingaCommandArgumentTable.php b/library/Director/Web/Table/IcingaCommandArgumentTable.php
new file mode 100644
index 0000000..37cbc78
--- /dev/null
+++ b/library/Director/Web/Table/IcingaCommandArgumentTable.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Data\Json;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchModificationStore;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaCommandArgumentTable extends ZfQueryBasedTable
+{
+ /** @var IcingaCommand */
+ protected $command;
+
+ /** @var Branch */
+ protected $branch;
+
+ protected $searchColumns = [
+ 'ca.argument_name',
+ 'ca.argument_value',
+ ];
+
+ public function __construct(IcingaCommand $command, Branch $branch)
+ {
+ $this->command = $command;
+ $this->branch = $branch;
+ parent::__construct($command->getConnection());
+ $this->getAttributes()->set('data-base-target', '_self');
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create($row->argument_name, 'director/command/arguments', [
+ 'argument' => $row->argument_name,
+ 'name' => $this->command->getObjectName()
+ ]),
+ $row->argument_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Argument'),
+ $this->translate('Value'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ if ($this->branch->isBranch()) {
+ return (new ArrayDatasource((array) $this->command->arguments()->toPlainObject()))->select();
+ /** @var Db $connection */
+ $connection = $this->connection();
+ $store = new BranchModificationStore($connection, 'command');
+ $modification = $store->loadOptionalModificationByName(
+ $this->command->getObjectName(),
+ $this->branch->getUuid()
+ );
+ if ($modification) {
+ $props = $modification->getProperties()->jsonSerialize();
+ if (isset($props->arguments)) {
+ return new ArrayDatasource((array) $this->command->arguments()->toPlainObject());
+ }
+ }
+ }
+ $id = $this->command->get('id');
+ if ($id === null) {
+ return new ArrayDatasource([]);
+ }
+ return $this->db()->select()->from(
+ ['ca' => 'icinga_command_argument'],
+ [
+ 'id' => 'ca.id',
+ 'argument_name' => "COALESCE(ca.argument_name, '(none)')",
+ 'argument_value' => 'ca.argument_value',
+ ]
+ )->where(
+ 'ca.command_id = ?',
+ $id
+ )->order('ca.sort_order')->order('ca.argument_name')->limit(100);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php b/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php
new file mode 100644
index 0000000..0d2f8e8
--- /dev/null
+++ b/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\CustomVariable\CustomVariableDictionary;
+use Icinga\Module\Director\Objects\IcingaHost;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class IcingaHostAppliedForServiceTable extends SimpleQueryBasedTable
+{
+ protected $title;
+
+ protected $host;
+
+ /** @var CustomVariableDictionary */
+ protected $cv;
+
+ protected $searchColumns = [
+ 'service',
+ ];
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ /**
+ * @param IcingaHost $host
+ * @param CustomVariableDictionary $dict
+ * @return static
+ */
+ public static function load(IcingaHost $host, CustomVariableDictionary $dict)
+ {
+ $table = (new static())->setHost($host)->setDictionary($dict);
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function setDictionary(CustomVariableDictionary $dict)
+ {
+ $this->cv = $dict;
+ return $this;
+ }
+
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->service) {
+ $link = Html::tag('span', ['class' => 'icon-right-big'], $row->service);
+ } else {
+ $link = $row->service;
+ }
+ } else {
+ $link = Link::create($row->service, 'director/host/appliedservice', [
+ 'name' => $this->host->object_name,
+ 'service' => $row->service,
+ ]);
+ }
+
+ return $this::row([$link]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->title ?: $this->translate('Service name'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $data = [];
+ foreach ($this->cv->getValue() as $key => $var) {
+ $data[] = (object) array(
+ 'service' => $key,
+ );
+ }
+
+ return (new ArrayDatasource($data))->select();
+ }
+}
diff --git a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php
new file mode 100644
index 0000000..415903b
--- /dev/null
+++ b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php
@@ -0,0 +1,207 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\IcingaConfig\AssignRenderer;
+use Icinga\Module\Director\Objects\HostApplyMatches;
+use Icinga\Module\Director\Objects\IcingaHost;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable
+{
+ protected $title;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ private $allApplyRules;
+
+ /**
+ * @param IcingaHost $host
+ * @return static
+ */
+ public static function load(IcingaHost $host)
+ {
+ $table = (new static())->setHost($host);
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->title];
+ }
+
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ $this->db = $host->getDb();
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ $classes = [];
+ if ($row->blacklisted === 'y') {
+ $classes[] = 'strike-links';
+ }
+ if ($row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+
+ $attributes = empty($classes) ? null : ['class' => $classes];
+
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->name) {
+ $link = Html::tag('a', ['class' => 'icon-right-big'], $row->name);
+ } else {
+ $link = Html::tag('a', $row->name);
+ }
+ } else {
+ $applyFor = '';
+ if (! empty($row->apply_for)) {
+ $applyFor = sprintf('(apply for %s) ', $row->apply_for);
+ }
+
+ $link = Link::create(sprintf(
+ $this->translate('%s %s(%s)'),
+ $row->name,
+ $applyFor,
+ $this->renderApplyFilter($row->filter)
+ ), 'director/host/appliedservice', [
+ 'name' => $this->host->getObjectName(),
+ 'service_id' => $row->id,
+ ]);
+ }
+
+ return $this::row([$link], $attributes);
+ }
+
+ /**
+ * @param Filter $assignFilter
+ *
+ * @return string
+ */
+ protected function renderApplyFilter(Filter $assignFilter)
+ {
+ try {
+ $string = AssignRenderer::forFilter($assignFilter)->renderAssign();
+ } catch (IcingaException $e) {
+ $string = 'Error in Filter rendering: ' . $e->getMessage();
+ }
+
+ return $string;
+ }
+
+ /**
+ * @return \Icinga\Data\SimpleQuery
+ */
+ public function prepareQuery()
+ {
+ $services = [];
+ $matcher = HostApplyMatches::prepare($this->host);
+ foreach ($this->getAllApplyRules() as $rule) {
+ if ($matcher->matchesFilter($rule->filter)) {
+ $services[] = $rule;
+ }
+ }
+
+ $ds = new ArrayDatasource($services);
+ return $ds->select()->columns([
+ 'id' => 'id',
+ 'uuid' => 'uuid',
+ 'name' => 'name',
+ 'filter' => 'filter',
+ 'disabled' => 'disabled',
+ 'blacklisted' => 'blacklisted',
+ 'assign_filter' => 'assign_filter',
+ 'apply_for' => 'apply_for',
+ ]);
+ }
+
+ /***
+ * @return array
+ */
+ protected function getAllApplyRules()
+ {
+ if ($this->allApplyRules === null) {
+ $this->allApplyRules = $this->fetchAllApplyRules();
+ foreach ($this->allApplyRules as $rule) {
+ $rule->filter = Filter::fromQueryString($rule->assign_filter);
+ }
+ }
+
+ return $this->allApplyRules;
+ }
+
+ /**
+ * @return array
+ */
+ protected function fetchAllApplyRules()
+ {
+ $db = $this->db;
+ $hostId = $this->host->get('id');
+ $query = $db->select()->from(
+ ['s' => 'icinga_service'],
+ [
+ 'id' => 's.id',
+ 'uuid' => 's.uuid',
+ 'name' => 's.object_name',
+ 'assign_filter' => 's.assign_filter',
+ 'apply_for' => 's.apply_for',
+ 'disabled' => 's.disabled',
+ 'blacklisted' => $hostId ? "CASE WHEN hsb.service_id IS NULL THEN 'n' ELSE 'y' END" : "('n')",
+ ]
+ )->where('object_type = ? AND assign_filter IS NOT NULL', 'apply')
+ ->order('s.object_name');
+ if ($hostId) {
+ $query->joinLeft(
+ ['hsb' => 'icinga_host_service_blacklist'],
+ $db->quoteInto('s.id = hsb.service_id AND hsb.host_id = ?', $hostId),
+ []
+ );
+ }
+
+ return $db->fetchAll($query);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php b/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php
new file mode 100644
index 0000000..8d225bf
--- /dev/null
+++ b/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Data\SimpleQueryPaginationAdapter;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\QueryBasedTable;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\SimpleQuery;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Resolver\IcingaHostObjectResolver;
+
+class IcingaHostsMatchingFilterTable extends QueryBasedTable
+{
+ protected $searchColumns = [
+ 'object_name',
+ ];
+
+ /** @var ArrayDatasource */
+ protected $dataSource;
+
+ public static function load(Filter $filter, Db $db)
+ {
+ $table = new static();
+ $table->dataSource = new ArrayDatasource(
+ (new IcingaHostObjectResolver($db->getDbAdapter()))
+ ->fetchObjectsMatchingFilter($filter)
+ );
+
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->object_name,
+ 'director/host',
+ ['name' => $row->object_name]
+ )
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Hostname'),
+ ];
+ }
+
+ protected function getPaginationAdapter()
+ {
+ return new SimpleQueryPaginationAdapter($this->getQuery());
+ }
+
+ public function getQuery()
+ {
+ return $this->prepareQuery();
+ }
+
+ protected function fetchQueryRows()
+ {
+ return $this->dataSource->fetchAll($this->getQuery());
+ }
+
+ protected function prepareQuery()
+ {
+ return new SimpleQuery($this->dataSource, ['object_name']);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaObjectDatafieldTable.php b/library/Director/Web/Table/IcingaObjectDatafieldTable.php
new file mode 100644
index 0000000..f97692e
--- /dev/null
+++ b/library/Director/Web/Table/IcingaObjectDatafieldTable.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Form\IcingaObjectFieldLoader;
+use Icinga\Web\Url;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class IcingaObjectDatafieldTable extends SimpleQueryBasedTable
+{
+ protected $object;
+
+ /** @var int */
+ protected $objectId;
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ $this->objectId = (int) $object->id;
+ return $this;
+ }
+
+ protected $searchColumns = array(
+ 'varname',
+ 'caption'
+ );
+
+ public function getColumns()
+ {
+ return array(
+ 'object_id',
+ 'var_filter',
+ 'is_required',
+ 'id',
+ 'varname',
+ 'caption',
+ 'description',
+ 'datatype',
+ 'format',
+ );
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ 'caption' => $this->translate('Label'),
+ 'varname' => $this->translate('Field name'),
+ 'is_required' => $this->translate('Mandatory'),
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $definedOnThis = (int) $row->object_id === $this->objectId;
+ if ($definedOnThis) {
+ $caption = new Link(
+ $row->caption,
+ Url::fromRequest()->with('field_id', $row->id)
+ );
+ } else {
+ $caption = $row->caption;
+ }
+
+ $row = $this::row([
+ $caption,
+ $row->varname,
+ $row->is_required
+ ]);
+
+ if (! $definedOnThis) {
+ $row->getAttributes()->add('class', 'disabled');
+ }
+
+ return $row;
+ }
+
+ public function prepareQuery()
+ {
+ $loader = new IcingaObjectFieldLoader($this->object);
+ $fields = $loader->fetchFieldDetailsForObject($this->object);
+ $ds = new ArrayDatasource($fields);
+ return $ds->select();
+ }
+}
diff --git a/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php b/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php
new file mode 100644
index 0000000..cd8f8b1
--- /dev/null
+++ b/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaScheduledDowntime;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaScheduledDowntimeRangeTable extends ZfQueryBasedTable
+{
+ /** @var IcingaScheduledDowntime */
+ protected $downtime;
+
+ protected $searchColumns = [
+ 'range_key',
+ 'range_value',
+ ];
+
+ /**
+ * @param IcingaScheduledDowntime $downtime
+ * @return static
+ */
+ public static function load(IcingaScheduledDowntime $downtime)
+ {
+ $table = new static($downtime->getConnection());
+ $table->downtime = $downtime;
+ $table->getAttributes()->set('data-base-target', '_self');
+
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->range_key,
+ 'director/scheduled-downtime/ranges',
+ [
+ 'name' => $this->downtime->getObjectName(),
+ 'range' => $row->range_key,
+ 'range_type' => 'include'
+ ]
+ ),
+ $row->range_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Day(s)'),
+ $this->translate('Timeperiods'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['r' => 'icinga_scheduled_downtime_range'],
+ [
+ 'scheduled_downtime_id' => 'r.scheduled_downtime_id',
+ 'range_key' => 'r.range_key',
+ 'range_value' => 'r.range_value',
+ ]
+ )->where('r.scheduled_downtime_id = ?', $this->downtime->id);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaServiceSetHostTable.php b/library/Director/Web/Table/IcingaServiceSetHostTable.php
new file mode 100644
index 0000000..9fc3c61
--- /dev/null
+++ b/library/Director/Web/Table/IcingaServiceSetHostTable.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaServiceSetHostTable extends ZfQueryBasedTable
+{
+ protected $set;
+
+ protected $searchColumns = array(
+ 'host',
+ );
+
+ public static function load(IcingaServiceSet $set)
+ {
+ $table = new static($set->getConnection());
+ $table->set = $set;
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->host,
+ 'director/host',
+ ['name' => $row->host]
+ )
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Hostname'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['h' => 'icinga_host'],
+ [
+ 'id' => 'h.id',
+ 'host' => 'h.object_name',
+ 'object_type' => 'h.object_type',
+ ]
+ )->joinLeft(
+ ['ssh' => 'icinga_service_set'],
+ 'ssh.host_id = h.id',
+ []
+ )->joinLeft(
+ ['ssih' => 'icinga_service_set_inheritance'],
+ 'ssih.service_set_id = ssh.id',
+ []
+ )->where(
+ 'ssih.parent_service_set_id = ?',
+ $this->set->id
+ )->order('h.object_name');
+ }
+}
diff --git a/library/Director/Web/Table/IcingaServiceSetServiceTable.php b/library/Director/Web/Table/IcingaServiceSetServiceTable.php
new file mode 100644
index 0000000..c205e66
--- /dev/null
+++ b/library/Director/Web/Table/IcingaServiceSetServiceTable.php
@@ -0,0 +1,259 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Data\Db\ServiceSetQueryBuilder;
+use Icinga\Module\Director\Db;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use Icinga\Module\Director\Forms\RemoveLinkForm;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class IcingaServiceSetServiceTable extends ZfQueryBasedTable
+{
+ use TableWithBranchSupport;
+
+ /** @var IcingaServiceSet */
+ protected $set;
+
+ protected $title;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ /** @var IcingaHost */
+ protected $affectedHost;
+
+ protected $searchColumns = [
+ 'service',
+ ];
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ /**
+ * @param IcingaServiceSet $set
+ * @return static
+ */
+ public static function load(IcingaServiceSet $set)
+ {
+ $table = new static($set->getConnection());
+ $table->set = $set;
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ /**
+ * @param string $title
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * @param IcingaHost $host
+ * @return $this
+ */
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ return $this;
+ }
+
+ /**
+ * @param IcingaHost $host
+ * @return $this
+ */
+ public function setAffectedHost(IcingaHost $host)
+ {
+ $this->affectedHost = $host;
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ /**
+ * @param $row
+ * @return BaseHtmlElement
+ */
+ protected function getServiceLink($row)
+ {
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->service) {
+ return Html::tag('span', ['class' => 'ro-service icon-right-big'], $row->service);
+ }
+
+ return Html::tag('span', ['class' => 'ro-service'], $row->service);
+ }
+
+ if ($this->affectedHost) {
+ $params = [
+ 'uuid' => $this->affectedHost->getUniqueId()->toString(),
+ 'service' => $row->service,
+ 'set' => $row->service_set
+ ];
+ $url = 'director/host/servicesetservice';
+ } else {
+ $params = [
+ 'name' => $row->service,
+ 'set' => $row->service_set
+ ];
+ $url = 'director/service';
+ }
+
+ return Link::create(
+ $row->service,
+ $url,
+ $params
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ $this->getServiceLink($row)
+ ]);
+ $classes = $this->getRowClasses($row);
+ if ($row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+ if ($row->blacklisted === 'y') {
+ $classes[] = 'strike-links';
+ }
+ if (! empty($classes)) {
+ $tr->getAttributes()->add('class', $classes);
+ }
+
+ return $tr;
+ }
+
+ protected function getRowClasses($row)
+ {
+ if ($row->branch_uuid !== null) {
+ return ['branch_modified'];
+ }
+ return [];
+ }
+
+ protected function getTitle()
+ {
+ return $this->title ?: $this->translate('Servicename');
+ }
+
+ protected function renderTitleColumns()
+ {
+ if (! $this->host || ! $this->affectedHost) {
+ return Html::tag('th', $this->getTitle());
+ }
+
+ if ($this->readonly) {
+ $link = $this->createFakeRemoveLinkForReadonlyView();
+ } elseif ($this->affectedHost->get('id') !== $this->host->get('id')) {
+ $link = $this->linkToHost($this->host);
+ } else {
+ $link = $this->createRemoveLinkForm();
+ }
+
+ return $this::th([$this->getTitle(), $link]);
+ }
+
+ /**
+ * @return \Zend_Db_Select
+ * @throws \Zend_Db_Select_Exception
+ */
+ public function prepareQuery()
+ {
+ $connection = $this->connection();
+ assert($connection instanceof Db);
+ $builder = new ServiceSetQueryBuilder($connection, $this->branchUuid);
+ return $builder->selectServicesForSet($this->set)->limit(100);
+ }
+
+ protected function createFakeRemoveLinkForReadonlyView()
+ {
+ return Html::tag('span', [
+ 'class' => 'icon-paste',
+ 'style' => 'float: right; font-weight: normal',
+ ], $this->host->getObjectName());
+ }
+
+ protected function linkToHost(IcingaHost $host)
+ {
+ $hostname = $host->getObjectName();
+ return Link::create($hostname, 'director/host/services', ['name' => $hostname], [
+ 'class' => 'icon-paste',
+ 'style' => 'float: right; font-weight: normal',
+ 'data-base-target' => '_next',
+ 'title' => sprintf(
+ $this->translate('This set has been inherited from %s'),
+ $hostname
+ )
+ ]);
+ }
+
+ protected function createRemoveLinkForm()
+ {
+ $deleteLink = new RemoveLinkForm(
+ $this->translate('Remove'),
+ sprintf(
+ $this->translate('Remove "%s" from this host'),
+ $this->getTitle()
+ ),
+ Url::fromPath('director/host/services', [
+ 'name' => $this->host->getObjectName()
+ ]),
+ ['title' => $this->getTitle()]
+ );
+ $deleteLink->runOnSuccess(function () {
+ $conn = $this->set->getConnection();
+ $db = $conn->getDbAdapter();
+ $query = $db->select()->from(['ss' => 'icinga_service_set'], 'ss.id')
+ ->join(['ssih' => 'icinga_service_set_inheritance'], 'ssih.service_set_id = ss.id', [])
+ ->where('ssih.parent_service_set_id = ?', $this->set->get('id'))
+ ->where('ss.host_id = ?', $this->host->get('id'));
+ IcingaServiceSet::loadWithAutoIncId(
+ $db->fetchOne($query),
+ $conn
+ )->delete();
+ });
+ $deleteLink->handleRequest();
+ return $deleteLink;
+ }
+
+ public function removeQueryLimit()
+ {
+ $query = $this->getQuery();
+ $query->reset($query::LIMIT_OFFSET);
+ $query->reset($query::LIMIT_COUNT);
+
+ return $this;
+ }
+}
diff --git a/library/Director/Web/Table/IcingaTimePeriodRangeTable.php b/library/Director/Web/Table/IcingaTimePeriodRangeTable.php
new file mode 100644
index 0000000..5870e67
--- /dev/null
+++ b/library/Director/Web/Table/IcingaTimePeriodRangeTable.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaTimePeriod;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaTimePeriodRangeTable extends ZfQueryBasedTable
+{
+ protected $period;
+
+ protected $searchColumns = array(
+ 'range_key',
+ 'range_value',
+ );
+
+ public static function load(IcingaTimePeriod $period)
+ {
+ $table = new static($period->getConnection());
+ $table->period = $period;
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->range_key,
+ 'director/timeperiod/ranges',
+ array(
+ 'name' => $this->period->object_name,
+ 'range' => $row->range_key,
+ 'range_type' => 'include'
+ )
+ ),
+ $row->range_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Day(s)'),
+ $this->translate('Timeperiods'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['r' => 'icinga_timeperiod_range'],
+ [
+ 'timeperiod_id' => 'r.timeperiod_id',
+ 'range_key' => 'r.range_key',
+ 'range_value' => 'r.range_value',
+ ]
+ )->where('r.timeperiod_id = ?', $this->period->id);
+ }
+}
diff --git a/library/Director/Web/Table/ImportedrowsTable.php b/library/Director/Web/Table/ImportedrowsTable.php
new file mode 100644
index 0000000..d5c9811
--- /dev/null
+++ b/library/Director/Web/Table/ImportedrowsTable.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Objects\ImportRun;
+use Icinga\Module\Director\PlainObjectRenderer;
+
+class ImportedrowsTable extends SimpleQueryBasedTable
+{
+ protected $columns;
+
+ /** @var ImportRun */
+ protected $importRun;
+
+ protected $keyColumn;
+
+ public static function load(ImportRun $run)
+ {
+ $table = new static();
+ $table->setImportRun($run);
+ return $table;
+ }
+
+ public function setImportRun(ImportRun $run)
+ {
+ $this->importRun = $run;
+ return $this;
+ }
+
+ public function setColumns($columns)
+ {
+ $this->columns = $columns;
+ return $this;
+ }
+
+ protected function getKeyColumn()
+ {
+ if ($this->keyColumn === null) {
+ $this->keyColumn = $this->importRun->importSource()->get('key_column');
+ }
+
+ return $this->keyColumn;
+ }
+
+ public function getColumns()
+ {
+ if ($this->columns === null) {
+ $cols = $this->importRun->listColumnNames();
+
+ $keyColumn = $this->getKeyColumn();
+ if ($keyColumn !== null && ($pos = array_search($keyColumn, $cols)) !== false) {
+ unset($cols[$pos]);
+ array_unshift($cols, $keyColumn);
+ }
+ } else {
+ $cols = $this->columns;
+ }
+
+ return array_combine($cols, $cols);
+ }
+
+ public function renderRow($row)
+ {
+ // Find a better place!
+ if ($row === null) {
+ return null;
+ }
+ $tr = $this::tr();
+
+ foreach ($this->getColumnsToBeRendered() as $column) {
+ $td = $this::td();
+ if (property_exists($row, $column)) {
+ if (is_string($row->$column) || $row->$column instanceof ValidHtml) {
+ $td->setContent($row->$column);
+ } else {
+ $html = Html::tag('pre', null, PlainObjectRenderer::render($row->$column));
+ $td->setContent($html);
+ }
+ }
+ $tr->add($td);
+ }
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return $this->getColumns();
+ }
+
+ public function prepareQuery()
+ {
+ $ds = new ArrayDatasource(
+ $this->importRun->fetchRows($this->columns)
+ );
+
+ return $ds->select()->order($this->getKeyColumn());
+ }
+}
diff --git a/library/Director/Web/Table/ImportrunTable.php b/library/Director/Web/Table/ImportrunTable.php
new file mode 100644
index 0000000..e6c8a38
--- /dev/null
+++ b/library/Director/Web/Table/ImportrunTable.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\ImportSource;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class ImportrunTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ /** @var ImportSource */
+ protected $source;
+
+ protected $searchColumns = [
+ 'source_name',
+ ];
+
+ public static function load(ImportSource $source)
+ {
+ $table = new static($source->getConnection());
+ $table->source = $source;
+ return $table;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Source name'),
+ $this->translate('Timestamp'),
+ $this->translate('Imported rows'),
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->source_name,
+ 'director/importrun',
+ ['id' => $row->id]
+ ),
+ $row->start_time,
+ $row->cnt_rows
+ ]);
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $columns = array(
+ 'id' => 'r.id',
+ 'source_id' => 's.id',
+ 'source_name' => 's.source_name',
+ 'start_time' => 'r.start_time',
+ 'rowset' => 'LOWER(HEX(rs.checksum))',
+ 'cnt_rows' => 'COUNT(rsr.row_checksum)',
+ );
+
+ if ($this->isPgsql()) {
+ $columns['rowset'] = "LOWER(ENCODE(rs.checksum, 'hex'))";
+ }
+
+ // TODO: Store row count to rowset
+ $query = $db->select()->from(
+ ['s' => 'import_source'],
+ $columns
+ )->join(
+ ['r' => 'import_run'],
+ 'r.source_id = s.id',
+ []
+ )->joinLeft(
+ ['rs' => 'imported_rowset'],
+ 'rs.checksum = r.rowset_checksum',
+ []
+ )->joinLeft(
+ ['rsr' => 'imported_rowset_row'],
+ 'rs.checksum = rsr.rowset_checksum',
+ []
+ )->group('r.id')->group('s.id')->group('rs.checksum')
+ ->order('r.start_time DESC');
+
+ if ($this->source) {
+ $query->where('r.source_id = ?', $this->source->get('id'));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ImportsourceHookTable.php b/library/Director/Web/Table/ImportsourceHookTable.php
new file mode 100644
index 0000000..5ddb6f3
--- /dev/null
+++ b/library/Director/Web/Table/ImportsourceHookTable.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\ValidHtml;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Import\SyncUtils;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\PlainObjectRenderer;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class ImportsourceHookTable extends SimpleQueryBasedTable
+{
+ /** @var ImportSource */
+ protected $source;
+
+ protected $columnCache;
+
+ /** @var ImportSourceHook */
+ protected $sourceHook;
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'raw-data-table collapsed');
+ }
+
+ public function getColumns()
+ {
+ if ($this->columnCache === null) {
+ $this->columnCache = SyncUtils::getRootVariables(array_merge(
+ $this->sourceHook()->listColumns(),
+ $this->source->listModifierTargetProperties()
+ ));
+
+ sort($this->columnCache);
+
+ // prioritize key column
+ $keyColumn = $this->source->get('key_column');
+ if ($keyColumn !== null && ($pos = array_search($keyColumn, $this->columnCache)) !== false) {
+ unset($this->columnCache[$pos]);
+ array_unshift($this->columnCache, $keyColumn);
+ }
+ }
+
+ return $this->columnCache;
+ }
+
+ public function setImportSource(ImportSource $source)
+ {
+ $this->source = $source;
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return $this->getColumns();
+ }
+
+ public function renderRow($row)
+ {
+ // Find a better place!
+ if ($row === null) {
+ return null;
+ }
+ if (\is_array($row)) {
+ $row = (object) $row;
+ }
+ $tr = $this::tr();
+
+ foreach ($this->getColumnsToBeRendered() as $column) {
+ $td = $this::td();
+ if (\property_exists($row, $column)) {
+ if (\is_string($row->$column) || $row->$column instanceof ValidHtml) {
+ $td->setContent($row->$column);
+ } else {
+ $html = Html::tag('pre', null, PlainObjectRenderer::render($row->$column));
+ $td->setContent($html);
+ }
+ }
+ $tr->add($td);
+ }
+
+ return $tr;
+ }
+
+ protected function sourceHook()
+ {
+ if ($this->sourceHook === null) {
+ $this->sourceHook = ImportSourceHook::forImportSource(
+ $this->source
+ );
+ }
+
+ return $this->sourceHook;
+ }
+
+ public function prepareQuery()
+ {
+ $data = $this->sourceHook()->fetchData();
+ $this->source->applyModifiers($data);
+
+ $ds = new ArrayDatasource($data);
+ return $ds->select();
+ }
+}
diff --git a/library/Director/Web/Table/ImportsourceTable.php b/library/Director/Web/Table/ImportsourceTable.php
new file mode 100644
index 0000000..1a93ef5
--- /dev/null
+++ b/library/Director/Web/Table/ImportsourceTable.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class ImportsourceTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'source_name',
+ 'description',
+ ];
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Source name'),
+ ];
+ }
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'syncstate');
+ parent::assemble();
+ }
+
+ public function renderRow($row)
+ {
+ $caption = [Link::create(
+ $row->source_name,
+ 'director/importsource',
+ ['id' => $row->id]
+ )];
+ if ($row->description !== null) {
+ $caption[] = ': ' . $row->description;
+ }
+
+ if ($row->import_state === 'failing' && $row->last_error_message) {
+ $caption[] = ' (' . $row->last_error_message . ')';
+ }
+
+ $tr = $this::row([$caption]);
+ $tr->getAttributes()->add('class', $row->import_state);
+
+ return $tr;
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['s' => 'import_source'],
+ [
+ 'id' => 's.id',
+ 'source_name' => 's.source_name',
+ 'provider_class' => 's.provider_class',
+ 'import_state' => 's.import_state',
+ 'last_error_message' => 's.last_error_message',
+ 'description' => 's.description',
+ ]
+ )->order('source_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/JobTable.php b/library/Director/Web/Table/JobTable.php
new file mode 100644
index 0000000..81ba07b
--- /dev/null
+++ b/library/Director/Web/Table/JobTable.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class JobTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'job_name',
+ ];
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'jobs');
+ parent::assemble();
+ }
+
+ public function renderRow($row)
+ {
+ $caption = [Link::create(
+ $row->job_name,
+ 'director/job',
+ ['id' => $row->id]
+ )];
+
+ if ($row->last_attempt_succeeded === 'n' && $row->last_error_message) {
+ $caption[] = ' (' . $row->last_error_message . ')';
+ }
+
+ $tr = $this::row([$caption]);
+ $tr->getAttributes()->add('class', $this->getJobClasses($row));
+
+ return $tr;
+ }
+
+ protected function getJobClasses($row)
+ {
+ if ($row->unixts_last_attempt === null) {
+ return 'pending';
+ }
+
+ if ($row->unixts_last_attempt + $row->run_interval < time()) {
+ return 'pending';
+ }
+
+ if ($row->last_attempt_succeeded === 'y') {
+ return 'ok';
+ } elseif ($row->last_attempt_succeeded === 'n') {
+ return 'critical';
+ } else {
+ return 'unknown';
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Job name'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['j' => 'director_job'],
+ [
+ 'id' => 'j.id',
+ 'job_name' => 'j.job_name',
+ 'job_class' => 'j.job_class',
+ 'disabled' => 'j.disabled',
+ 'run_interval' => 'j.run_interval',
+ 'last_attempt_succeeded' => 'j.last_attempt_succeeded',
+ 'ts_last_attempt' => 'j.ts_last_attempt',
+ 'unixts_last_attempt' => 'UNIX_TIMESTAMP(j.ts_last_attempt)',
+ 'ts_last_error' => 'j.ts_last_error',
+ 'last_error_message' => 'j.last_error_message',
+ ]
+ )->order('job_name');
+ }
+}
diff --git a/library/Director/Web/Table/NotificationTemplateUsageTable.php b/library/Director/Web/Table/NotificationTemplateUsageTable.php
new file mode 100644
index 0000000..da411a3
--- /dev/null
+++ b/library/Director/Web/Table/NotificationTemplateUsageTable.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class NotificationTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'applyrules' => $this->translate('Apply Rules'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'applyrules' => $this->getSummaryLine('apply', 'o.host_id IS NULL'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/ObjectSetTable.php b/library/Director/Web/Table/ObjectSetTable.php
new file mode 100644
index 0000000..2773841
--- /dev/null
+++ b/library/Director/Web/Table/ObjectSetTable.php
@@ -0,0 +1,211 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Db\DbSelectParenthesis;
+use Icinga\Module\Director\Restriction\FilterByNameRestriction;
+use ipl\Html\Html;
+use Ramsey\Uuid\Uuid;
+
+class ObjectSetTable extends ZfQueryBasedTable
+{
+ use TableWithBranchSupport;
+
+ protected $searchColumns = [
+ 'os.object_name',
+ 'os.description',
+ 'os.assign_filter',
+ 'o.object_name',
+ ];
+
+ private $type;
+
+ /** @var Auth */
+ private $auth;
+
+ public static function create($type, Db $db, Auth $auth)
+ {
+ $table = new static($db);
+ $table->type = $type;
+ $table->auth = $auth;
+ return $table;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Name')];
+ }
+
+ public function renderRow($row)
+ {
+ $type = $this->getType();
+ $params = [
+ 'uuid' => Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid))->toString(),
+ ];
+
+ $url = Url::fromPath("director/${type}set", $params);
+
+ $classes = $this->getRowClasses($row);
+ $tr = static::tr([
+ static::td([
+ Link::create(sprintf(
+ $this->translate('%s (%d members)'),
+ $row->object_name,
+ $row->count_services
+ ), $url),
+ $row->description ? [Html::tag('br'), Html::tag('i', $row->description)] : null
+ ])
+ ]);
+ if (! empty($classes)) {
+ $tr->getAttributes()->add('class', $classes);
+ }
+
+ return $tr;
+ }
+
+ protected function getRowClasses($row)
+ {
+ if ($row->branch_uuid !== null) {
+ return ['branch_modified'];
+ }
+ return [];
+ }
+
+ protected function prepareQuery()
+ {
+ $type = $this->getType();
+
+ $table = "icinga_${type}_set";
+ $columns = [
+ 'id' => 'os.id',
+ 'uuid' => 'os.uuid',
+ 'branch_uuid' => '(NULL)',
+ 'object_name' => 'os.object_name',
+ 'object_type' => 'os.object_type',
+ 'assign_filter' => 'os.assign_filter',
+ 'description' => 'os.description',
+ 'count_services' => 'COUNT(DISTINCT o.uuid)',
+ ];
+ if ($this->branchUuid) {
+ $columns['branch_uuid'] = 'bos.branch_uuid';
+ $columns = $this->branchifyColumns($columns);
+ $this->stripSearchColumnAliases();
+ }
+
+ $query = $this->db()->select()->from(
+ ['os' => $table],
+ $columns
+ )->joinLeft(
+ ['o' => "icinga_${type}"],
+ "o.${type}_set_id = os.id",
+ []
+ );
+
+ $nameFilter = new FilterByNameRestriction(
+ $this->connection(),
+ $this->auth,
+ "${type}_set"
+ );
+ $nameFilter->applyToQuery($query, 'os');
+ /** @var Db $conn */
+ $conn = $this->connection();
+ if ($this->branchUuid) {
+ $right = clone($query);
+
+ $query->joinLeft(
+ ['bos' => "branched_$table"],
+ // TODO: PgHexFunc
+ $this->db()->quoteInto(
+ 'bos.uuid = os.uuid AND bos.branch_uuid = ?',
+ $conn->quoteBinary($this->branchUuid->getBytes())
+ ),
+ []
+ )->where("(bos.branch_deleted IS NULL OR bos.branch_deleted = 'n')");
+ $right->joinRight(
+ ['bos' => "branched_$table"],
+ 'bos.uuid = os.uuid',
+ []
+ )
+ ->where('os.uuid IS NULL')
+ ->where('bos.branch_uuid = ?', $conn->quoteBinary($this->branchUuid->getBytes()));
+ $query->group('COALESCE(os.uuid, bos.uuid)');
+ $right->group('COALESCE(os.uuid, bos.uuid)');
+ if ($conn->isPgsql()) {
+ // This is ugly, might want to modify the query - even a subselect looks better
+ $query->group('bos.uuid')->group('os.uuid')->group('os.id')->group('bos.branch_uuid');
+ $right->group('bos.uuid')->group('os.uuid')->group('os.id')->group('bos.branch_uuid');
+ }
+
+ $query = $this->db()->select()->union([
+ 'l' => new DbSelectParenthesis($query),
+ 'r' => new DbSelectParenthesis($right),
+ ]);
+ $query = $this->db()->select()->from(['u' => $query]);
+ $query->order('object_name')->limit(100);
+
+ $query
+ ->group('uuid')
+ ->where('object_type = ?', 'template')
+ ->order('object_name');
+ if ($conn->isPgsql()) {
+ // BS. Drop count? Sub-select? Better query?
+ $query
+ ->group('uuid')
+ ->group('id')
+ ->group('branch_uuid')
+ ->group('object_name')
+ ->group('object_type')
+ ->group('assign_filter')
+ ->group('description')
+ ->group('count_services');
+ };
+ } else {
+ // Disabled for now, check for correctness:
+ // $query->joinLeft(
+ // ['osi' => "icinga_${type}_set_inheritance"],
+ // "osi.parent_${type}_set_id = os.id",
+ // []
+ // )->joinLeft(
+ // ['oso' => "icinga_${type}_set"],
+ // "oso.id = oso.${type}_set_id",
+ // []
+ // );
+ // 'count_hosts' => 'COUNT(DISTINCT oso.id)',
+
+ $query
+ ->group('os.uuid')
+ ->where('os.object_type = ?', 'template')
+ ->order('os.object_name');
+ if ($conn->isPgsql()) {
+ // BS. Drop count? Sub-select? Better query?
+ $query
+ ->group('os.uuid')
+ ->group('os.id')
+ ->group('os.object_name')
+ ->group('os.object_type')
+ ->group('os.assign_filter')
+ ->group('os.description');
+ };
+ }
+
+ return $query;
+ }
+
+ /**
+ * @return Db
+ */
+ public function connection()
+ {
+ return parent::connection();
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTable.php b/library/Director/Web/Table/ObjectsTable.php
new file mode 100644
index 0000000..792cb6d
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTable.php
@@ -0,0 +1,315 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\DbSelectParenthesis;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Restriction\FilterByNameRestriction;
+use Icinga\Module\Director\Restriction\HostgroupRestriction;
+use Icinga\Module\Director\Restriction\ObjectRestriction;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use Ramsey\Uuid\Uuid;
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTable extends ZfQueryBasedTable
+{
+ use TableWithBranchSupport;
+
+ /** @var ObjectRestriction[] */
+ protected $objectRestrictions;
+
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'object_type' => 'o.object_type',
+ 'disabled' => 'o.disabled',
+ 'uuid' => 'o.uuid',
+ ];
+
+ protected $searchColumns = ['o.object_name'];
+
+ protected $showColumns = ['object_name' => 'Name'];
+
+ protected $filterObjectType = 'object';
+
+ protected $type;
+
+ protected $baseObjectUrl;
+
+ /** @var IcingaObject */
+ protected $dummyObject;
+
+ protected $leftSubQuery;
+
+ protected $rightSubQuery;
+
+ /** @var Auth */
+ private $auth;
+
+ /**
+ * @param $type
+ * @param Db $db
+ * @return static
+ */
+ public static function create($type, Db $db)
+ {
+ $class = __NAMESPACE__ . '\\ObjectsTable' . ucfirst($type);
+ if (! class_exists($class)) {
+ $class = __CLASS__;
+ }
+
+ /** @var static $table */
+ $table = new $class($db);
+ $table->type = $type;
+ return $table;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ /**
+ * @param string $url
+ * @return $this
+ */
+ public function setBaseObjectUrl($url)
+ {
+ $this->baseObjectUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * @return Auth
+ */
+ public function getAuth()
+ {
+ return $this->auth;
+ }
+
+ public function setAuth(Auth $auth)
+ {
+ $this->auth = $auth;
+ return $this;
+ }
+
+ public function filterObjectType($type)
+ {
+ $this->filterObjectType = $type;
+ return $this;
+ }
+
+ public function addObjectRestriction(ObjectRestriction $restriction)
+ {
+ $this->objectRestrictions[$restriction->getName()] = $restriction;
+ return $this;
+ }
+
+ public function getColumns()
+ {
+ return $this->columns;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return $this->showColumns;
+ }
+
+ public function filterTemplate(
+ IcingaObject $template,
+ $inheritance = Db\IcingaObjectFilterHelper::INHERIT_DIRECT
+ ) {
+ IcingaObjectFilterHelper::filterByTemplate(
+ $this->getQuery(),
+ $template,
+ 'o',
+ $inheritance
+ );
+
+ return $this;
+ }
+
+ protected function getMainLinkLabel($row)
+ {
+ return $row->object_name;
+ }
+
+ protected function renderObjectNameColumn($row)
+ {
+ $type = $this->baseObjectUrl;
+ $url = Url::fromPath("director/${type}", [
+ 'uuid' => Uuid::fromBytes($row->uuid)->toString()
+ ]);
+
+ return static::td(Link::create($this->getMainLinkLabel($row), $url));
+ }
+
+ protected function renderExtraColumns($row)
+ {
+ $columns = $this->getColumnsToBeRendered();
+ unset($columns['object_name']);
+ $cols = [];
+ foreach ($columns as $key => & $label) {
+ $cols[] = static::td($row->$key);
+ }
+
+ return $cols;
+ }
+
+ public function renderRow($row)
+ {
+ if (isset($row->uuid) && is_resource($row->uuid)) {
+ $row->uuid = stream_get_contents($row->uuid);
+ }
+ $tr = static::tr([
+ $this->renderObjectNameColumn($row),
+ $this->renderExtraColumns($row)
+ ]);
+
+ $classes = $this->getRowClasses($row);
+ if ($row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+ if (! empty($classes)) {
+ $tr->getAttributes()->add('class', $classes);
+ }
+
+ return $tr;
+ }
+
+ protected function getRowClasses($row)
+ {
+ // TODO: remove isset, to figure out where it is missing
+ if (isset($row->branch_uuid) && $row->branch_uuid !== null) {
+ return ['branch_modified'];
+ }
+ return [];
+ }
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ if ($right) {
+ $right->where(
+ 'bo.object_type = ?',
+ $this->filterObjectType
+ );
+ }
+ return $query->where(
+ 'o.object_type = ?',
+ $this->filterObjectType
+ );
+ }
+
+ protected function applyRestrictions(ZfSelect $query)
+ {
+ foreach ($this->getRestrictions() as $restriction) {
+ $restriction->applyToQuery($query);
+ }
+
+ return $query;
+ }
+
+ protected function getRestrictions()
+ {
+ if ($this->objectRestrictions === null) {
+ $this->objectRestrictions = $this->loadRestrictions();
+ }
+
+ return $this->objectRestrictions;
+ }
+
+ protected function loadRestrictions()
+ {
+ /** @var Db $db */
+ $db = $this->connection();
+ $auth = $this->getAuth();
+
+ return [
+ new HostgroupRestriction($db, $auth),
+ new FilterByNameRestriction($db, $auth, $this->getDummyObject()->getShortTableName())
+ ];
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ protected function getDummyObject()
+ {
+ if ($this->dummyObject === null) {
+ $type = $this->getType();
+ $this->dummyObject = IcingaObject::createByType($type);
+ }
+ return $this->dummyObject;
+ }
+
+ protected function prepareQuery()
+ {
+ $table = $this->getDummyObject()->getTableName();
+ if ($this->branchUuid) {
+ $this->columns['branch_uuid'] = 'bo.branch_uuid';
+ }
+
+ $columns = $this->getColumns();
+ if ($this->branchUuid) {
+ $columns = $this->branchifyColumns($columns);
+ $this->stripSearchColumnAliases();
+ }
+ $query = $this->db()->select()->from(['o' => $table], $columns);
+
+ if ($this->branchUuid) {
+ $right = clone($query);
+ // Hint: Right part has only those with object = null
+ // This means that restrictions on $right would hide all
+ // new rows. Dedicated restriction logic for the branch-only
+ // part of thw union are not required, we assume that restrictions
+ // for new objects have been checked once they have been created
+ $query = $this->applyRestrictions($query);
+ /** @var Db $conn */
+ $conn = $this->connection();
+ $query->joinLeft(
+ ['bo' => "branched_$table"],
+ // TODO: PgHexFunc
+ $this->db()->quoteInto(
+ 'bo.uuid = o.uuid AND bo.branch_uuid = ?',
+ $conn->quoteBinary($this->branchUuid->getBytes())
+ ),
+ []
+ )->where("(bo.branch_deleted IS NULL OR bo.branch_deleted = 'n')");
+ $this->applyObjectTypeFilter($query, $right);
+ $right->joinRight(
+ ['bo' => "branched_$table"],
+ 'bo.uuid = o.uuid',
+ []
+ )
+ ->where('o.uuid IS NULL')
+ ->where('bo.branch_uuid = ?', $conn->quoteBinary($this->branchUuid->getBytes()));
+ $this->leftSubQuery = $query;
+ $this->rightSubQuery = $right;
+ $query = $this->db()->select()->union([
+ 'l' => new DbSelectParenthesis($query),
+ 'r' => new DbSelectParenthesis($right),
+ ]);
+ $query = $this->db()->select()->from(['u' => $query]);
+ $query->order('object_name')->limit(100);
+ } else {
+ $this->applyObjectTypeFilter($query);
+ $query->order('o.object_name')->limit(100);
+ }
+
+ return $query;
+ }
+
+ public function removeQueryLimit()
+ {
+ $query = $this->getQuery();
+ $query->reset($query::LIMIT_OFFSET);
+ $query->reset($query::LIMIT_COUNT);
+
+ return $this;
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableApiUser.php b/library/Director/Web/Table/ObjectsTableApiUser.php
new file mode 100644
index 0000000..2287c2f
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableApiUser.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableApiUser extends ObjectsTable
+{
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query->where("o.object_type IN ('object', 'external_object')");
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableCommand.php b/library/Director/Web/Table/ObjectsTableCommand.php
new file mode 100644
index 0000000..ebd89da
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableCommand.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableCommand extends ObjectsTable implements FilterableByUsage
+{
+ // TODO: Notifications separately?
+ protected $searchColumns = [
+ 'o.object_name',
+ 'o.command',
+ ];
+
+ protected $columns = [
+ 'uuid' => 'o.uuid',
+ 'object_name' => 'o.object_name',
+ 'object_type' => 'o.object_type',
+ 'disabled' => 'o.disabled',
+ 'command' => 'o.command',
+ ];
+
+ protected $showColumns = [
+ 'object_name' => 'Command',
+ 'command' => 'Command line'
+ ];
+
+ private $objectType;
+
+ public function setType($type)
+ {
+ $this->getQuery()->where('object_type = ?', $type);
+
+ return $this;
+ }
+
+ public function showOnlyUsed()
+ {
+ $this->getQuery()->where(
+ '('
+ . 'EXISTS (SELECT check_command_id FROM icinga_host WHERE check_command_id = o.id)'
+ . ' OR EXISTS (SELECT check_command_id FROM icinga_service WHERE check_command_id = o.id)'
+ . ' OR EXISTS (SELECT event_command_id FROM icinga_host WHERE event_command_id = o.id)'
+ . ' OR EXISTS (SELECT event_command_id FROM icinga_service WHERE event_command_id = o.id)'
+ . ' OR EXISTS (SELECT command_id FROM icinga_notification WHERE command_id = o.id)'
+ . ')'
+ );
+ }
+
+ public function showOnlyUnUsed()
+ {
+ $this->getQuery()->where(
+ '('
+ . 'NOT EXISTS (SELECT check_command_id FROM icinga_host WHERE check_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT check_command_id FROM icinga_service WHERE check_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT event_command_id FROM icinga_host WHERE event_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT event_command_id FROM icinga_service WHERE event_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT command_id FROM icinga_notification WHERE command_id = o.id)'
+ . ')'
+ );
+ }
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableEndpoint.php b/library/Director/Web/Table/ObjectsTableEndpoint.php
new file mode 100644
index 0000000..f73b38b
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableEndpoint.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Icon;
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableEndpoint extends ObjectsTable
+{
+ protected $searchColumns = [
+ 'o.object_name',
+ ];
+
+ protected $deploymentEndpoint;
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ 'object_name' => $this->translate('Endpoint'),
+ 'host' => $this->translate('Host'),
+ 'zone' => $this->translate('Zone'),
+ 'object_type' => $this->translate('Type'),
+ );
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'uuid' => 'o.uuid',
+ 'object_name' => 'o.object_name',
+ 'object_type' => 'o.object_type',
+ 'disabled' => 'o.disabled',
+ 'host' => "(CASE WHEN o.host IS NULL THEN NULL ELSE"
+ . " CONCAT(o.host || ':' || COALESCE(o.port, 5665)) END)",
+ 'zone' => 'z.object_name',
+ ];
+ }
+
+ protected function getMainLinkLabel($row)
+ {
+ if ($row->object_name === $this->deploymentEndpoint) {
+ return [
+ $row->object_name,
+ ' ',
+ Icon::create('upload', [
+ 'title' => $this->translate(
+ 'This is your Config master and will receive our Deployments'
+ )
+ ])
+ ];
+ } else {
+ return $row->object_name;
+ }
+ }
+
+ public function getRowClasses($row)
+ {
+ if ($row->object_name === $this->deploymentEndpoint) {
+ return array_merge(array('deployment-endpoint'), parent::getRowClasses($row));
+ } else {
+ return null;
+ }
+ }
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query->where("o.object_type IN ('object', 'external_object')");
+ }
+
+ public function prepareQuery()
+ {
+ if ($this->deploymentEndpoint === null) {
+ /** @var \Icinga\Module\Director\Db $c */
+ $c = $this->connection();
+ if ($c->hasDeploymentEndpoint()) {
+ $this->deploymentEndpoint = $c->getDeploymentEndpointName();
+ }
+ }
+
+ return parent::prepareQuery()->joinLeft(
+ ['z' => 'icinga_zone'],
+ 'o.zone_id = z.id',
+ []
+ );
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableHost.php b/library/Director/Web/Table/ObjectsTableHost.php
new file mode 100644
index 0000000..5128e04
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableHost.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+
+class ObjectsTableHost extends ObjectsTable
+{
+ use MultiSelect;
+
+ protected $type = 'host';
+
+ protected $searchColumns = [
+ 'o.object_name',
+ 'o.display_name',
+ 'o.address',
+ ];
+
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'display_name' => 'o.display_name',
+ 'address' => 'o.address',
+ 'disabled' => 'o.disabled',
+ 'uuid' => 'o.uuid',
+ ];
+
+ protected $showColumns = [
+ 'object_name' => 'Hostname',
+ 'address' => 'Address'
+ ];
+
+ public function assemble()
+ {
+ $this->enableMultiSelect(
+ 'director/hosts/edit',
+ 'director/hosts',
+ ['uuid']
+ );
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php b/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php
new file mode 100644
index 0000000..929e050
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableHostTemplateChoice extends ObjectsTable
+{
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'templates' => 'GROUP_CONCAT(t.object_name)'
+ ];
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query;
+ }
+
+ protected function prepareQuery()
+ {
+ return parent::prepareQuery()->joinLeft(
+ ['t' => 'icinga_host'],
+ 't.template_choice_id = o.id',
+ []
+ )->group('o.id');
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableService.php b/library/Director/Web/Table/ObjectsTableService.php
new file mode 100644
index 0000000..2d4ad41
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableService.php
@@ -0,0 +1,219 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db\DbUtil;
+use Icinga\Module\Director\Objects\IcingaHost;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+use gipfl\IcingaWeb2\Link;
+use Ramsey\Uuid\Uuid;
+
+class ObjectsTableService extends ObjectsTable
+{
+ use MultiSelect;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ protected $type = 'service';
+
+ protected $title;
+
+ /** @var IcingaHost */
+ protected $inheritedBy;
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'disabled' => 'o.disabled',
+ 'host' => 'h.object_name',
+ 'host_id' => 'h.id',
+ 'host_object_type' => 'h.object_type',
+ 'host_disabled' => 'h.disabled',
+ 'id' => 'o.id',
+ 'uuid' => 'o.uuid',
+ 'blacklisted' => "CASE WHEN hsb.service_id IS NULL THEN 'n' ELSE 'y' END",
+ ];
+
+ protected $searchColumns = [
+ 'o.object_name',
+ 'h.object_name'
+ ];
+
+ public function assemble()
+ {
+ $this->enableMultiSelect(
+ 'director/services/edit',
+ 'director/services',
+ ['uuid']
+ );
+ }
+
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ $this->getAttributes()->set('data-base-target', '_self');
+ return $this;
+ }
+
+ public function setInheritedBy(IcingaHost $host)
+ {
+ $this->inheritedBy = $host;
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ if ($this->title) {
+ return [$this->title];
+ }
+ if ($this->host) {
+ return [$this->translate('Servicename')];
+ }
+ return [
+ 'host' => $this->translate('Host'),
+ 'object_name' => $this->translate('Service Name'),
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ $caption = $row->host === null
+ ? Html::tag('span', ['class' => 'error'], '- none -')
+ : $row->host;
+
+ $hostField = static::td($caption);
+ if ($row->host === null) {
+ $hostField->getAttributes()->add('class', 'error');
+ }
+ if ($this->host) {
+ $tr = static::tr([
+ static::td($this->getServiceLink($row))
+ ]);
+ } else {
+ $tr = static::tr([
+ $hostField,
+ static::td($this->getServiceLink($row))
+ ]);
+ }
+
+ $attributes = $tr->getAttributes();
+ $classes = $this->getRowClasses($row);
+ if ($row->host_disabled === 'y' || $row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+ if ($row->blacklisted === 'y') {
+ $classes[] = 'strike-links';
+ }
+ $attributes->add('class', $classes);
+
+ return $tr;
+ }
+
+ protected function getInheritedServiceLink($row, $target)
+ {
+ $params = [
+ 'name' => $target->object_name,
+ 'service' => $row->object_name,
+ 'inheritedFrom' => $row->host,
+ ];
+
+ return Link::create(
+ $row->object_name,
+ 'director/host/inheritedservice',
+ $params
+ );
+ }
+
+ protected function getServiceLink($row)
+ {
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->object_name) {
+ return Html::tag('span', ['class' => 'icon-right-big'], $row->object_name);
+ } else {
+ return $row->object_name;
+ }
+ }
+
+ $params = [
+ 'uuid' => Uuid::fromBytes(DbUtil::binaryResult($row->uuid))->toString(),
+ ];
+ if ($row->host !== null) {
+ $params['host'] = $row->host;
+ }
+ if ($target = $this->inheritedBy) {
+ return $this->getInheritedServiceLink($row, $target);
+ }
+
+ return Link::create(
+ $row->object_name,
+ 'director/service/edit',
+ $params
+ );
+ }
+
+ public function prepareQuery()
+ {
+ $query = parent::prepareQuery();
+ if ($this->branchUuid) {
+ $queries = [$this->leftSubQuery, $this->rightSubQuery];
+ } else {
+ $queries = [$query];
+ }
+
+ foreach ($queries as $subQuery) {
+ $subQuery->joinLeft(
+ ['h' => 'icinga_host'],
+ 'o.host_id = h.id',
+ []
+ )->joinLeft(
+ ['hsb' => 'icinga_host_service_blacklist'],
+ 'hsb.service_id = o.id AND hsb.host_id = o.host_id',
+ []
+ )->where('o.service_set_id IS NULL')
+ ->order('o.object_name')->order('h.object_name');
+
+ if ($this->host) {
+ if ($this->branchUuid) {
+ $subQuery->where('COALESCE(h.object_name, bo.host) = ?', $this->host->getObjectName());
+ } else {
+ $subQuery->where('h.id = ?', $this->host->get('id'));
+ }
+ }
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableZone.php b/library/Director/Web/Table/ObjectsTableZone.php
new file mode 100644
index 0000000..602cf0a
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableZone.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableZone extends ObjectsTable
+{
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/PropertymodifierTable.php b/library/Director/Web/Table/PropertymodifierTable.php
new file mode 100644
index 0000000..bf9e4a3
--- /dev/null
+++ b/library/Director/Web/Table/PropertymodifierTable.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Error;
+use Exception;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Objects\ImportSource;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\Extension\ZfSortablePriority;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class PropertymodifierTable extends ZfQueryBasedTable
+{
+ use ZfSortablePriority;
+
+ protected $searchColumns = [
+ 'property_name',
+ 'target_property',
+ ];
+
+ /** @var ImportSource */
+ protected $source;
+
+ /** @var Url */
+ protected $url;
+
+ protected $keyColumn = 'id';
+
+ protected $priorityColumn = 'priority';
+
+ protected $readOnly = false;
+
+ public static function load(ImportSource $source, Url $url)
+ {
+ $table = new static($source->getConnection());
+ $table->source = $source;
+ $table->url = $url;
+ return $table;
+ }
+
+ public function setReadOnly($readOnly = true)
+ {
+ $this->readOnly = $readOnly;
+ return $this;
+ }
+
+ public function render()
+ {
+ if ($this->readOnly) {
+ return parent::render();
+ }
+ return $this->renderWithSortableForm();
+ }
+
+ protected function assemble()
+ {
+ $this->getAttributes()->set('data-base-target', '_self');
+ }
+
+ public function getColumns()
+ {
+ return array(
+ 'id' => 'm.id',
+ 'source_id' => 'm.source_id',
+ 'property_name' => 'm.property_name',
+ 'target_property' => 'm.target_property',
+ 'description' => 'm.description',
+ 'provider_class' => 'm.provider_class',
+ 'priority' => 'm.priority',
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $caption = $row->property_name;
+ if ($row->target_property !== null) {
+ $caption .= ' -> ' . $row->target_property;
+ }
+ if ($row->description === null) {
+ $class = $row->provider_class;
+ try {
+ /** @var ImportSourceHook $hook */
+ $hook = new $class;
+ $caption .= ': ' . $hook->getName();
+ } catch (Exception $e) {
+ $caption = $this->createErrorCaption($caption, $e);
+ } catch (Error $e) {
+ $caption = $this->createErrorCaption($caption, $e);
+ }
+ } else {
+ $caption .= ': ' . $row->description;
+ }
+
+ $renderedRow = $this::row([
+ Link::create($caption, 'director/importsource/editmodifier', [
+ 'id' => $row->id,
+ 'source_id' => $row->source_id,
+ ]),
+ ]);
+ if ($this->readOnly) {
+ return $renderedRow;
+ }
+
+ return $this->addSortPriorityButtons(
+ $renderedRow,
+ $row
+ );
+ }
+
+ /**
+ * @param $caption
+ * @param Exception|Error $e
+ * @return array
+ */
+ protected function createErrorCaption($caption, $e)
+ {
+ return [
+ $caption,
+ ': ',
+ $this::tag('span', ['class' => 'error'], $e->getMessage())
+ ];
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ if ($this->readOnly) {
+ return [$this->translate('Property')];
+ }
+ return [
+ $this->translate('Property'),
+ $this->getSortPriorityTitle()
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['m' => 'import_row_modifier'],
+ $this->getColumns()
+ )->where('m.source_id = ?', $this->source->get('id'))
+ ->order('priority');
+ }
+}
diff --git a/library/Director/Web/Table/QuickTable.php b/library/Director/Web/Table/QuickTable.php
new file mode 100644
index 0000000..ff3edcc
--- /dev/null
+++ b/library/Director/Web/Table/QuickTable.php
@@ -0,0 +1,547 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Application\Icinga;
+use Icinga\Data\Filter\FilterAnd;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Filter\FilterNot;
+use Icinga\Data\Filter\FilterOr;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Selectable;
+use Icinga\Data\Paginatable;
+use Icinga\Exception\QueryException;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\PlainObjectRenderer;
+use Icinga\Web\Request;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Web\View;
+use Icinga\Web\Widget;
+use Icinga\Web\Widget\Paginator;
+use ipl\Html\ValidHtml;
+use stdClass;
+use Zend_Db_Select as ZfDbSelect;
+
+abstract class QuickTable implements Paginatable, ValidHtml
+{
+ protected $view;
+
+ /** @var Db */
+ protected $connection;
+
+ protected $limit;
+
+ protected $offset;
+
+ /** @var Filter */
+ protected $filter;
+
+ protected $enforcedFilters = array();
+
+ protected $searchColumns = array();
+
+ protected function getRowClasses($row)
+ {
+ return array();
+ }
+
+ protected function getRowClassesString($row)
+ {
+ return $this->createClassAttribute($this->getRowClasses($row));
+ }
+
+ protected function createClassAttribute($classes)
+ {
+ $str = $this->createClassesString($classes);
+ if (strlen($str) > 0) {
+ return ' class="' . $str . '"';
+ } else {
+ return '';
+ }
+ }
+
+ private function createClassesString($classes)
+ {
+ if (is_string($classes)) {
+ $classes = array($classes);
+ }
+
+ if (empty($classes)) {
+ return '';
+ } else {
+ return implode(' ', $classes);
+ }
+ }
+
+ protected function getMultiselectProperties()
+ {
+ /* array(
+ * 'url' => 'director/hosts/edit',
+ * 'sourceUrl' => 'director/hosts',
+ * 'keys' => 'name'
+ * ) */
+
+ return array();
+ }
+
+ protected function renderMultiselectAttributes()
+ {
+ $props = $this->getMultiselectProperties();
+
+ if (empty($props)) {
+ return '';
+ }
+
+ $prefix = 'data-icinga-multiselect-';
+ $view = $this->view();
+ $parts = array();
+ $multi = array(
+ 'url' => $view->href($props['url']),
+ 'controllers' => $view->href($props['sourceUrl']),
+ 'data' => implode(',', $props['keys']),
+ );
+
+ foreach ($multi as $k => $v) {
+ $parts[] = $prefix . $k . '="' . $v . '"';
+ }
+
+ return ' ' . implode(' ', $parts);
+ }
+
+ protected function renderRow($row)
+ {
+ $htm = " <tr" . $this->getRowClassesString($row) . ">\n";
+ $firstCol = true;
+
+ foreach ($this->getTitles() as $key => $title) {
+ // Support missing columns
+ if (property_exists($row, $key)) {
+ $val = $row->$key;
+ } else {
+ $val = null;
+ }
+
+ $value = null;
+
+ if ($firstCol) {
+ if ($val !== null && $url = $this->getActionUrl($row)) {
+ $value = $this->view()->qlink($val, $this->getActionUrl($row));
+ }
+ $firstCol = false;
+ }
+
+ if ($value === null) {
+ if ($val === null) {
+ $value = '-';
+ } elseif (is_array($val) || $val instanceof stdClass || is_bool($val)) {
+ $value = '<pre>'
+ . $this->view()->escape(PlainObjectRenderer::render($val))
+ . '</pre>';
+ } else {
+ $value = $this->view()->escape($val);
+ }
+ }
+
+ $htm .= ' <td>' . $value . "</td>\n";
+ }
+
+ if ($this->hasAdditionalActions()) {
+ $htm .= ' <td class="actions">' . $this->renderAdditionalActions($row) . "</td>\n";
+ }
+
+ return $htm . " </tr>\n";
+ }
+
+ abstract protected function getTitles();
+
+ protected function getActionUrl($row)
+ {
+ return false;
+ }
+
+ public function setConnection(Selectable $connection)
+ {
+ $this->connection = $connection;
+ return $this;
+ }
+
+ /**
+ * @return ZfDbSelect
+ */
+ abstract protected function getBaseQuery();
+
+ public function fetchData()
+ {
+ $db = $this->db();
+ $query = $this->getBaseQuery()->columns($this->getColumns());
+
+ if ($this->hasLimit() || $this->hasOffset()) {
+ $query->limit($this->getLimit(), $this->getOffset());
+ }
+
+ $this->applyFiltersToQuery($query);
+
+ return $db->fetchAll($query);
+ }
+
+ protected function applyFiltersToQuery(ZfDbSelect $query)
+ {
+ $filter = null;
+ $enforced = $this->enforcedFilters;
+ if ($this->filter && ! $this->filter->isEmpty()) {
+ $filter = $this->filter;
+ } elseif (! empty($enforced)) {
+ $filter = array_shift($enforced);
+ }
+ if ($filter) {
+ foreach ($enforced as $f) {
+ $filter = $filter->andFilter($f);
+ }
+ $query->where($this->renderFilter($filter));
+ }
+
+ return $query;
+ }
+
+ public function getPaginator()
+ {
+ $paginator = new Paginator();
+ $paginator->setQuery($this);
+
+ return $paginator;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ $db = $this->db();
+ $query = clone($this->getBaseQuery());
+ $query->reset('order')->columns(array('COUNT(*)'));
+ $this->applyFiltersToQuery($query);
+
+ return $db->fetchOne($query);
+ }
+
+ public function limit($count = null, $offset = null)
+ {
+ $this->limit = $count;
+ $this->offset = $offset;
+
+ return $this;
+ }
+
+ public function hasLimit()
+ {
+ return $this->limit !== null;
+ }
+
+ public function getLimit()
+ {
+ return $this->limit;
+ }
+
+ public function hasOffset()
+ {
+ return $this->offset !== null;
+ }
+
+ public function getOffset()
+ {
+ return $this->offset;
+ }
+
+ public function hasAdditionalActions()
+ {
+ return method_exists($this, 'renderAdditionalActions');
+ }
+
+ /** @return Db */
+ protected function connection()
+ {
+ // TODO: Fail if missing? Require connection in constructor?
+ return $this->connection;
+ }
+
+ protected function db()
+ {
+ return $this->connection()->getDbAdapter();
+ }
+
+ protected function renderTitles($row)
+ {
+ $view = $this->view();
+ $htm = "<thead>\n <tr>\n";
+
+ foreach ($row as $title) {
+ $htm .= ' <th>' . $view->escape($title) . "</th>\n";
+ }
+
+ if ($this->hasAdditionalActions()) {
+ $htm .= ' <th class="actions">' . $view->translate('Actions') . "</th>\n";
+ }
+
+ return $htm . " </tr>\n</thead>\n";
+ }
+
+ protected function url($url, $params)
+ {
+ return Url::fromPath($url, $params);
+ }
+
+ protected function listTableClasses()
+ {
+ $classes = array('simple', 'common-table', 'table-row-selectable');
+ $multi = $this->getMultiselectProperties();
+ if (! empty($multi)) {
+ $classes[] = 'multiselect';
+ }
+
+ return $classes;
+ }
+
+ public function render()
+ {
+ $data = $this->fetchData();
+
+ $htm = '<table'
+ . $this->createClassAttribute($this->listTableClasses())
+ . $this->renderMultiselectAttributes()
+ . '>' . "\n"
+ . $this->renderTitles($this->getTitles())
+ . $this->beginTableBody();
+ foreach ($data as $row) {
+ $htm .= $this->renderRow($row);
+ }
+ return $htm . $this->endTableBody() . $this->endTable();
+ }
+
+ protected function beginTableBody()
+ {
+ return "<tbody>\n";
+ }
+
+ protected function endTableBody()
+ {
+ return "</tbody>\n";
+ }
+
+ protected function endTable()
+ {
+ return "</table>\n";
+ }
+
+ /**
+ * @return View
+ */
+ protected function view()
+ {
+ if ($this->view === null) {
+ $this->view = Icinga::app()->getViewRenderer()->view;
+ }
+ return $this->view;
+ }
+
+
+ public function setView($view)
+ {
+ $this->view = $view;
+ }
+
+ public function __toString()
+ {
+ return $this->render();
+ }
+
+ protected function getSearchColumns()
+ {
+ return $this->searchColumns;
+ }
+
+ abstract public function getColumns();
+
+ public function getFilterColumns()
+ {
+ $keys = array_keys($this->getColumns());
+ return array_combine($keys, $keys);
+ }
+
+ public function setFilter($filter)
+ {
+ $this->filter = $filter;
+ return $this;
+ }
+
+ public function enforceFilter($filter, $expression = null)
+ {
+ if (! $filter instanceof Filter) {
+ $filter = Filter::where($filter, $expression);
+ }
+ $this->enforcedFilters[] = $filter;
+ return $this;
+ }
+
+ public function getFilterEditor(Request $request)
+ {
+ $filterEditor = Widget::create('filterEditor')
+ ->setColumns(array_keys($this->getColumns()))
+ ->setSearchColumns($this->getSearchColumns())
+ ->preserveParams('limit', 'sort', 'dir', 'view', 'backend', '_dev')
+ ->ignoreParams('page')
+ ->handleRequest($request);
+
+ $filter = $filterEditor->getFilter();
+ $this->setFilter($filter);
+
+ return $filterEditor;
+ }
+
+ protected function mapFilterColumn($col)
+ {
+ $cols = $this->getColumns();
+ return $cols[$col];
+ }
+
+ protected function renderFilter(Filter $filter, $level = 0)
+ {
+ $str = '';
+ if ($filter instanceof FilterChain) {
+ if ($filter instanceof FilterAnd) {
+ $op = ' AND ';
+ } elseif ($filter instanceof FilterOr) {
+ $op = ' OR ';
+ } elseif ($filter instanceof FilterNot) {
+ $op = ' AND ';
+ $str .= ' NOT ';
+ } else {
+ throw new QueryException(
+ 'Cannot render filter: %s',
+ $filter
+ );
+ }
+ $parts = array();
+ if (! $filter->isEmpty()) {
+ foreach ($filter->filters() as $f) {
+ $filterPart = $this->renderFilter($f, $level + 1);
+ if ($filterPart !== '') {
+ $parts[] = $filterPart;
+ }
+ }
+ if (! empty($parts)) {
+ if ($level > 0) {
+ $str .= ' (' . implode($op, $parts) . ') ';
+ } else {
+ $str .= implode($op, $parts);
+ }
+ }
+ }
+ } else {
+ /** @var FilterExpression $filter */
+ $str .= $this->whereToSql(
+ $this->mapFilterColumn($filter->getColumn()),
+ $filter->getSign(),
+ $filter->getExpression()
+ );
+ }
+
+ return $str;
+ }
+
+ protected function escapeForSql($value)
+ {
+ // bindParam? bindValue?
+ if (is_array($value)) {
+ $ret = array();
+ foreach ($value as $val) {
+ $ret[] = $this->escapeForSql($val);
+ }
+ return implode(', ', $ret);
+ } else {
+ //if (preg_match('/^\d+$/', $value)) {
+ // return $value;
+ //} else {
+ return $this->db()->quote($value);
+ //}
+ }
+ }
+
+ protected function escapeWildcards($value)
+ {
+ return preg_replace('/\*/', '%', $value);
+ }
+
+ protected function valueToTimestamp($value)
+ {
+ // We consider integers as valid timestamps. Does not work for URL params
+ if (! is_string($value) || ctype_digit($value)) {
+ return $value;
+ }
+ $value = strtotime($value);
+ if (! $value) {
+ /*
+ NOTE: It's too late to throw exceptions, we might finish in __toString
+ throw new QueryException(sprintf(
+ '"%s" is not a valid time expression',
+ $value
+ ));
+ */
+ }
+ return $value;
+ }
+
+ protected function timestampForSql($value)
+ {
+ // TODO: do this db-aware
+ return $this->escapeForSql(date('Y-m-d H:i:s', $value));
+ }
+
+ /**
+ * Check for timestamp fields
+ *
+ * TODO: This is not here to do automagic timestamp stuff. One may
+ * override this function for custom voodoo, IdoQuery right now
+ * does. IMO we need to split whereToSql functionality, however
+ * I'd prefer to wait with this unless we understood how other
+ * backends will work. We probably should also rename this
+ * function to isTimestampColumn().
+ *
+ * @param string $field Field Field name to checked
+ * @return bool Whether this field expects timestamps
+ */
+ public function isTimestamp($field)
+ {
+ return false;
+ }
+
+ public function whereToSql($col, $sign, $expression)
+ {
+ if ($this->isTimestamp($col)) {
+ $expression = $this->valueToTimestamp($expression);
+ }
+
+ if (is_array($expression) && $sign === '=') {
+ // TODO: Should we support this? Doesn't work for blub*
+ return $col . ' IN (' . $this->escapeForSql($expression) . ')';
+ } elseif ($sign === '=' && strpos($expression, '*') !== false) {
+ if ($expression === '*') {
+ // We'll ignore such filters as it prevents index usage and because "*" means anything, anything means
+ // all whereas all means that whether we use a filter to match anything or no filter at all makes no
+ // difference, except for performance reasons...
+ return '';
+ }
+
+ return $col . ' LIKE ' . $this->escapeForSql($this->escapeWildcards($expression));
+ } elseif ($sign === '!=' && strpos($expression, '*') !== false) {
+ if ($expression === '*') {
+ // We'll ignore such filters as it prevents index usage and because "*" means nothing, so whether we're
+ // using a real column with a valid comparison here or just an expression which cannot be evaluated to
+ // true makes no difference, except for performance reasons...
+ return $this->escapeForSql(0);
+ }
+
+ return $col . ' NOT LIKE ' . $this->escapeForSql($this->escapeWildcards($expression));
+ } else {
+ return $col . ' ' . $sign . ' ' . $this->escapeForSql($expression);
+ }
+ }
+}
diff --git a/library/Director/Web/Table/ReadOnlyFormAvpTable.php b/library/Director/Web/Table/ReadOnlyFormAvpTable.php
new file mode 100644
index 0000000..c3b44f3
--- /dev/null
+++ b/library/Director/Web/Table/ReadOnlyFormAvpTable.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\PlainObjectRenderer;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Zend_Form_Element as ZfElement;
+use Zend_Form_DisplayGroup as ZfDisplayGroup;
+
+class ReadOnlyFormAvpTable
+{
+ protected $form;
+
+ public function __construct(QuickForm $form)
+ {
+ $this->form = $form;
+ }
+
+ protected function renderDisplayGroups(QuickForm $form)
+ {
+ $html = '';
+
+ foreach ($form->getDisplayGroups() as $group) {
+ $elements = $this->filterGroupElements($group);
+
+ if (empty($elements)) {
+ continue;
+ }
+
+ $html .= '<tr><th colspan="2" style="text-align: right">' . $group->getLegend() . '</th></tr>';
+ $html .= $this->renderElements($elements);
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param ZfDisplayGroup $group
+ * @return ZfElement[]
+ */
+ protected function filterGroupElements(ZfDisplayGroup $group)
+ {
+ $blacklist = array('disabled', 'assign_filter');
+ $elements = array();
+ /** @var ZfElement $element */
+ foreach ($group->getElements() as $element) {
+ if ($element->getValue() === null) {
+ continue;
+ }
+
+ if ($element->getType() === 'Zend_Form_Element_Hidden') {
+ continue;
+ }
+
+ if (in_array($element->getName(), $blacklist)) {
+ continue;
+ }
+
+
+ $elements[] = $element;
+ }
+
+ return $elements;
+ }
+
+ protected function renderElements($elements)
+ {
+ $html = '';
+ foreach ($elements as $element) {
+ $html .= $this->renderElement($element);
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param ZfElement $element
+ *
+ * @return string
+ */
+ protected function renderElement(ZfElement $element)
+ {
+ $value = $element->getValue();
+ return '<tr><th>'
+ . $this->escape($element->getLabel())
+ . '</th><td>'
+ . $this->renderValue($value)
+ . '</td></tr>';
+ }
+
+ protected function renderValue($value)
+ {
+ if (is_string($value)) {
+ return $this->escape($value);
+ } elseif (is_array($value)) {
+ return $this->escape(implode(', ', $value));
+ }
+ return $this->escape(PlainObjectRenderer::render($value));
+ }
+
+ protected function escape($string)
+ {
+ return htmlspecialchars($string);
+ }
+
+ public function render()
+ {
+ $this->form->initializeForObject();
+ return '<table class="name-value-table">' . "\n"
+ . $this->renderDisplayGroups($this->form)
+ . '</table>';
+ }
+}
diff --git a/library/Director/Web/Table/ServiceTemplateUsageTable.php b/library/Director/Web/Table/ServiceTemplateUsageTable.php
new file mode 100644
index 0000000..82f9643
--- /dev/null
+++ b/library/Director/Web/Table/ServiceTemplateUsageTable.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class ServiceTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'objects' => $this->translate('Objects'),
+ 'applyrules' => $this->translate('Apply Rules'),
+ // 'setmembers' => $this->translate('Set Members'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'objects' => $this->getSummaryLine('object'),
+ 'applyrules' => $this->getSummaryLine('apply', 'o.service_set_id IS NULL'),
+ // TODO: re-enable
+ // 'setmembers' => $this->getSummaryLine('apply', 'o.service_set_id IS NOT NULL'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/SyncRunTable.php b/library/Director/Web/Table/SyncRunTable.php
new file mode 100644
index 0000000..e08aad7
--- /dev/null
+++ b/library/Director/Web/Table/SyncRunTable.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\Format\LocalTimeFormat;
+use Icinga\Module\Director\Objects\SyncRule;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class SyncRunTable extends ZfQueryBasedTable
+{
+ /** @var SyncRule */
+ protected $rule;
+
+ protected $timeFormat;
+
+ public function __construct(SyncRule $rule)
+ {
+ parent::__construct($rule->getConnection());
+ $this->timeFormat = new LocalTimeFormat();
+ $this->getAttributes()
+ ->set('data-base-target', '_self')
+ ->add('class', 'history');
+ $this->rule = $rule;
+ }
+
+ public function renderRow($row)
+ {
+ $time = strtotime($row->start_time);
+ $this->renderDayIfNew($time);
+ return $this::tr([
+ $this::td($this->makeSummary($row)),
+ $this::td(new Link(
+ $this->timeFormat->getTime($time),
+ 'director/syncrule/history',
+ [
+ 'id' => $row->rule_id,
+ 'run_id' => $row->id,
+ ]
+ ))
+ ]);
+ }
+
+ protected function makeSummary($row)
+ {
+ $parts = [];
+ if ($row->objects_created > 0) {
+ $parts[] = sprintf(
+ $this->translate('%d created'),
+ $row->objects_created
+ );
+ }
+ if ($row->objects_modified > 0) {
+ $parts[] = sprintf(
+ $this->translate('%d modified'),
+ $row->objects_modified
+ );
+ }
+ if ($row->objects_deleted > 0) {
+ $parts[] = sprintf(
+ $this->translate('%d deleted'),
+ $row->objects_deleted
+ );
+ }
+
+ return implode(', ', $parts);
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ array('sr' => 'sync_run'),
+ [
+ 'id' => 'sr.id',
+ 'rule_id' => 'sr.rule_id',
+ 'rule_name' => 'sr.rule_name',
+ 'start_time' => 'sr.start_time',
+ 'duration_ms' => 'sr.duration_ms',
+ 'objects_deleted' => 'sr.objects_deleted',
+ 'objects_created' => 'sr.objects_created',
+ 'objects_modified' => 'sr.objects_modified',
+ 'last_former_activity' => 'sr.last_former_activity',
+ 'last_related_activity' => 'sr.last_related_activity',
+ ]
+ )->where(
+ 'sr.rule_id = ?',
+ $this->rule->get('id')
+ )->order('start_time DESC');
+ }
+}
diff --git a/library/Director/Web/Table/SyncpropertyTable.php b/library/Director/Web/Table/SyncpropertyTable.php
new file mode 100644
index 0000000..79461ce
--- /dev/null
+++ b/library/Director/Web/Table/SyncpropertyTable.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\SyncRule;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\Extension\ZfSortablePriority;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class SyncpropertyTable extends ZfQueryBasedTable
+{
+ use ZfSortablePriority;
+
+ /** @var SyncRule */
+ protected $rule;
+
+ protected $searchColumns = [
+ 'source_expression',
+ 'destination_field',
+ ];
+
+ protected $keyColumn = 'id';
+
+ protected $priorityColumn = 'priority';
+
+ public static function create(SyncRule $rule)
+ {
+ $table = new static($rule->getConnection());
+ $table->getAttributes()->set('data-base-target', '_self');
+ $table->rule = $rule;
+ return $table;
+ }
+
+ public function render()
+ {
+ return $this->renderWithSortableForm();
+ }
+
+ public function renderRow($row)
+ {
+ return $this->addSortPriorityButtons(
+ $this::row([
+ $row->source_name,
+ $row->source_expression,
+ new Link(
+ $row->destination_field,
+ 'director/syncrule/editproperty',
+ [
+ 'id' => $row->id,
+ 'rule_id' => $row->rule_id,
+ ]
+ ),
+ ]),
+ $row
+ );
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Source name'),
+ $this->translate('Source field'),
+ $this->translate('Destination'),
+ $this->getSortPriorityTitle()
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['p' => 'sync_property'],
+ [
+ 'id' => 'p.id',
+ 'rule_id' => 'p.rule_id',
+ 'rule_name' => 'r.rule_name',
+ 'source_id' => 'p.source_id',
+ 'source_name' => 's.source_name',
+ 'source_expression' => 'p.source_expression',
+ 'destination_field' => 'p.destination_field',
+ 'priority' => 'p.priority',
+ 'filter_expression' => 'p.filter_expression',
+ 'merge_policy' => 'p.merge_policy'
+ ]
+ )->join(
+ ['r' => 'sync_rule'],
+ 'r.id = p.rule_id',
+ []
+ )->join(
+ ['s' => 'import_source'],
+ 's.id = p.source_id',
+ []
+ )->where(
+ 'p.rule_id = ?',
+ $this->rule->get('id')
+ )->order('p.priority');
+ }
+}
diff --git a/library/Director/Web/Table/SyncruleTable.php b/library/Director/Web/Table/SyncruleTable.php
new file mode 100644
index 0000000..4a8e4e5
--- /dev/null
+++ b/library/Director/Web/Table/SyncruleTable.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class SyncruleTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'rule_name',
+ 'description',
+ ];
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'syncstate');
+ parent::assemble();
+ }
+
+ public function renderRow($row)
+ {
+ $caption = [Link::create(
+ $row->rule_name,
+ 'director/syncrule',
+ ['id' => $row->id]
+ )];
+ if ($row->description !== null) {
+ $caption[] = ': ' . $row->description;
+ }
+
+ if ($row->sync_state === 'failing' && $row->last_error_message) {
+ $caption[] = ' (' . $row->last_error_message . ')';
+ }
+
+ $tr = $this::row([$caption, $row->object_type]);
+ $tr->getAttributes()->add('class', $row->sync_state);
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Rule name'),
+ $this->translate('Object type'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['s' => 'sync_rule'],
+ [
+ 'id' => 's.id',
+ 'rule_name' => 's.rule_name',
+ 'sync_state' => 's.sync_state',
+ 'object_type' => 's.object_type',
+ 'update_policy' => 's.update_policy',
+ 'purge_existing' => 's.purge_existing',
+ 'filter_expression' => 's.filter_expression',
+ 'last_error_message' => 's.last_error_message',
+ 'description' => 's.description',
+ ]
+ )->order('rule_name');
+ }
+}
diff --git a/library/Director/Web/Table/TableLoader.php b/library/Director/Web/Table/TableLoader.php
new file mode 100644
index 0000000..f7e378b
--- /dev/null
+++ b/library/Director/Web/Table/TableLoader.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use Icinga\Exception\ProgrammingError;
+
+class TableLoader
+{
+ /** @return QuickTable */
+ public static function load($name, Module $module = null)
+ {
+ if ($module === null) {
+ $basedir = Icinga::app()->getApplicationDir('tables');
+ $ns = '\\Icinga\\Web\\Tables\\';
+ } else {
+ $basedir = $module->getBaseDir() . '/application/tables';
+ $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Tables\\';
+ }
+ if (preg_match('~^[a-z0-9/]+$~i', $name)) {
+ $parts = preg_split('~/~', $name);
+ $class = ucfirst(array_pop($parts)) . 'Table';
+ $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class);
+ if (file_exists($file)) {
+ require_once($file);
+ /** @var QuickTable $class */
+ $class = $ns . $class;
+ return new $class();
+ }
+ }
+ throw new ProgrammingError(sprintf('Cannot load %s (%s), no such table', $name, $file));
+ }
+}
diff --git a/library/Director/Web/Table/TableWithBranchSupport.php b/library/Director/Web/Table/TableWithBranchSupport.php
new file mode 100644
index 0000000..7c5b15c
--- /dev/null
+++ b/library/Director/Web/Table/TableWithBranchSupport.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db\Branch\Branch;
+use Ramsey\Uuid\UuidInterface;
+
+trait TableWithBranchSupport
+{
+
+ /** @var UuidInterface|null */
+ protected $branchUuid;
+
+ /**
+ * Convenience method, only UUID is required
+ *
+ * @param Branch|null $branch
+ * @return $this
+ */
+ public function setBranch(Branch $branch = null)
+ {
+ if ($branch && $branch->isBranch()) {
+ $this->setBranchUuid($branch->getUuid());
+ }
+
+ return $this;
+ }
+
+ public function setBranchUuid(UuidInterface $uuid = null)
+ {
+ $this->branchUuid = $uuid;
+
+ return $this;
+ }
+
+ protected function branchifyColumns($columns)
+ {
+ $result = [
+ 'uuid' => 'COALESCE(o.uuid, bo.uuid)'
+ ];
+ $ignore = ['o.id', 'os.id', 'o.service_set_id', 'os.host_id'];
+ foreach ($columns as $alias => $column) {
+ if (substr($column, 0, 2) === 'o.' && ! in_array($column, $ignore)) {
+ // bo.column, o.column
+ $column = "COALESCE(b$column, $column)";
+ }
+ if (substr($column, 0, 3) === 'os.' && ! in_array($column, $ignore)) {
+ // bo.column, o.column
+ $column = "COALESCE(b$column, $column)";
+ }
+
+ // Used in Service Tables:
+ if ($column === 'h.object_name' && $alias = 'host') {
+ $column = "COALESCE(bo.host, $column)";
+ }
+
+ $result[$alias] = $column;
+ }
+
+ return $result;
+ }
+
+ protected function stripSearchColumnAliases()
+ {
+ foreach ($this->searchColumns as &$column) {
+ $column = preg_replace('/^[a-z]+\./', '', $column);
+ }
+ }
+}
diff --git a/library/Director/Web/Table/TemplateUsageTable.php b/library/Director/Web/Table/TemplateUsageTable.php
new file mode 100644
index 0000000..66e56ea
--- /dev/null
+++ b/library/Director/Web/Table/TemplateUsageTable.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Resolver\TemplateTree;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+
+class TemplateUsageTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = ['class' => 'pivot'];
+
+ protected $objectType;
+
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'objects' => $this->translate('Objects'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'objects' => $this->getSummaryLine('object'),
+ ];
+ }
+
+ /**
+ * @param IcingaObject $template
+ * @return TemplateUsageTable
+ */
+ public static function forTemplate(IcingaObject $template)
+ {
+ $type = ucfirst($template->getShortTableName());
+ $class = __NAMESPACE__ . "\\${type}TemplateUsageTable";
+ if (class_exists($class)) {
+ return new $class($template);
+ } else {
+ return new static($template);
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ '',
+ $this->translate('Direct'),
+ $this->translate('Indirect'),
+ $this->translate('Total')
+ ];
+ }
+
+ protected function __construct(IcingaObject $template)
+ {
+
+ if ($template->get('object_type') !== 'template') {
+ throw new ProgrammingError(
+ 'TemplateUsageTable expects a template, got %s',
+ $template->get('object_type')
+ );
+ }
+
+ $this->objectType = $objectType = $template->getShortTableName();
+ $types = $this->getTypes();
+ $usage = $this->getUsageSummary($template);
+
+ $used = false;
+ $rows = [];
+ foreach ($types as $type => $typeTitle) {
+ $tr = Table::tr(Table::th($typeTitle));
+ foreach (['direct', 'indirect', 'total'] as $inheritance) {
+ $count = $usage->$inheritance->$type;
+ if (! $used && $count > 0) {
+ $used = true;
+ }
+ $tr->add(
+ Table::td(
+ Link::create(
+ $count,
+ "director/${objectType}template/$type",
+ [
+ 'name' => $template->getObjectName(),
+ 'inheritance' => $inheritance
+ ]
+ )
+ )
+ );
+ }
+ $rows[] = $tr;
+ }
+
+ if ($used) {
+ $this->add($rows);
+ } else {
+ $this->add($this->translate('This template is not in use'));
+ }
+ }
+
+ protected function getUsageSummary(IcingaObject $template)
+ {
+ $id = $template->getAutoincId();
+ $connection = $template->getConnection();
+ $db = $connection->getDbAdapter();
+ $oType = $this->objectType;
+ $tree = new TemplateTree($oType, $connection);
+ $ids = $tree->listDescendantIdsFor($template);
+ if (empty($ids)) {
+ $ids = [0];
+ }
+
+ $baseQuery = $db->select()->from(
+ ['o' => 'icinga_' . $oType],
+ $this->getTypeSummaryDefinitions()
+ )->joinLeft(
+ ['oi' => "icinga_${oType}_inheritance"],
+ "oi.${oType}_id = o.id",
+ []
+ );
+
+ $query = clone($baseQuery);
+ $direct = $db->fetchRow(
+ $query->where("oi.parent_${oType}_id = ?", $id)
+ );
+ $query = clone($baseQuery);
+ $indirect = $db->fetchRow(
+ $query->where("oi.parent_${oType}_id IN (?)", $ids)
+ );
+ //$indirect->templates = count($ids) - 1;
+ $total = [];
+ $types = array_keys($this->getTypes());
+ foreach ($types as $type) {
+ $total[$type] = $direct->$type + $indirect->$type;
+ }
+
+ return (object) [
+ 'direct' => $direct,
+ 'indirect' => $indirect,
+ 'total' => (object) $total
+ ];
+ }
+
+ protected function getSummaryLine($type, $extra = null)
+ {
+ if ($extra !== null) {
+ $extra = " AND $extra";
+ }
+ return "COALESCE(SUM(CASE WHEN o.object_type = '${type}'${extra} THEN 1 ELSE 0 END), 0)";
+ }
+}
diff --git a/library/Director/Web/Table/TemplatesTable.php b/library/Director/Web/Table/TemplatesTable.php
new file mode 100644
index 0000000..be195b2
--- /dev/null
+++ b/library/Director/Web/Table/TemplatesTable.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\Objects\IcingaObject;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer;
+use Ramsey\Uuid\Uuid;
+use Zend_Db_Select as ZfSelect;
+
+class TemplatesTable extends ZfQueryBasedTable implements FilterableByUsage
+{
+ use MultiSelect;
+
+ protected $searchColumns = ['o.object_name'];
+
+ private $type;
+
+ public static function create($type, Db $db)
+ {
+ $table = new static($db);
+ $table->type = strtolower($type);
+ return $table;
+ }
+
+ protected function assemble()
+ {
+ $type = $this->type;
+ $this->enableMultiSelect(
+ "director/${type}s/edittemplates",
+ "director/${type}template",
+ ['name']
+ );
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Template Name')];
+ }
+
+ public function renderRow($row)
+ {
+ $name = $row->object_name;
+ $type = str_replace('_', '-', $this->getType());
+ $caption = $row->is_used === 'y' ? $name : [
+ $name,
+ Html::tag(
+ 'span',
+ ['style' => 'font-style: italic'],
+ $this->translate(' - not in use -')
+ )
+ ];
+
+ $url = Url::fromPath("director/${type}template/usage", [
+ 'name' => $name
+ ]);
+
+ return $this::row([
+ new Link($caption, $url),
+ [
+ new Link(new Icon('plus'), "director/$type/add", [
+ 'type' => 'object',
+ 'imports' => $name
+ ]),
+ new Link(new Icon('history'), "director/$type/history", [
+ 'uuid' => Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid))->toString(),
+ ])
+ ]
+ ]);
+ }
+
+ public function filterTemplate(
+ IcingaObject $template,
+ $inheritance = IcingaObjectFilterHelper::INHERIT_DIRECT
+ ) {
+ IcingaObjectFilterHelper::filterByTemplate(
+ $this->getQuery(),
+ $template,
+ 'o',
+ $inheritance
+ );
+
+ return $this;
+ }
+
+ public function showOnlyUsed()
+ {
+ $type = $this->getType();
+ $this->getQuery()->where(
+ "(EXISTS (SELECT ${type}_id FROM icinga_${type}_inheritance"
+ . " WHERE parent_${type}_id = o.id))"
+ );
+ }
+
+ public function showOnlyUnUsed()
+ {
+ $type = $this->getType();
+ $this->getQuery()->where(
+ "(NOT EXISTS (SELECT ${type}_id FROM icinga_${type}_inheritance"
+ . " WHERE parent_${type}_id = o.id))"
+ );
+ }
+
+ protected function applyRestrictions(ZfSelect $query)
+ {
+ $auth = Auth::getInstance();
+ $type = $this->type;
+ $restrictions = $auth->getRestrictions("director/$type/template/filter-by-name");
+ if (empty($restrictions)) {
+ return $query;
+ }
+
+ $filter = Filter::matchAny();
+ foreach ($restrictions as $restriction) {
+ $filter->addFilter(Filter::where('o.object_name', $restriction));
+ }
+
+ return FilterRenderer::applyToQuery($filter, $query);
+ }
+
+ protected function prepareQuery()
+ {
+ $type = $this->getType();
+ $used = "CASE WHEN EXISTS(SELECT 1 FROM icinga_${type}_inheritance oi"
+ . " WHERE oi.parent_${type}_id = o.id) THEN 'y' ELSE 'n' END";
+
+ $columns = [
+ 'object_name' => 'o.object_name',
+ 'uuid' => 'o.uuid',
+ 'id' => 'o.id',
+ 'is_used' => $used,
+ ];
+ $query = $this->db()->select()->from(
+ ['o' => "icinga_${type}"],
+ $columns
+ )->where(
+ "o.object_type = 'template'"
+ )->order('o.object_name');
+
+ return $this->applyRestrictions($query);
+ }
+}
diff --git a/library/Director/Web/Tabs/DataTabs.php b/library/Director/Web/Tabs/DataTabs.php
new file mode 100644
index 0000000..ac29310
--- /dev/null
+++ b/library/Director/Web/Tabs/DataTabs.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class DataTabs extends Tabs
+{
+ use TranslationHelper;
+
+ public function __construct()
+ {
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ $this->add('datafield', [
+ 'label' => $this->translate('Data fields'),
+ 'url' => 'director/data/fields'
+ ])->add('datafieldcategory', [
+ 'label' => $this->translate('Data field categories'),
+ 'url' => 'director/data/fieldcategories'
+ ])->add('datalist', [
+ 'label' => $this->translate('Data lists'),
+ 'url' => 'director/data/lists'
+ ])->add('customvars', [
+ 'label' => $this->translate('Custom Variables'),
+ 'url' => 'director/data/vars'
+ ]);
+ }
+}
diff --git a/library/Director/Web/Tabs/ImportTabs.php b/library/Director/Web/Tabs/ImportTabs.php
new file mode 100644
index 0000000..e6c6807
--- /dev/null
+++ b/library/Director/Web/Tabs/ImportTabs.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ImportTabs extends Tabs
+{
+ use TranslationHelper;
+
+ public function __construct()
+ {
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ $this->add('importsource', [
+ 'label' => $this->translate('Import source'),
+ 'url' => 'director/importsources'
+ ])->add('syncrule', [
+ 'label' => $this->translate('Sync rule'),
+ 'url' => 'director/syncrules'
+ ])->add('jobs', [
+ 'label' => $this->translate('Jobs'),
+ 'url' => 'director/jobs'
+ ]);
+ }
+}
diff --git a/library/Director/Web/Tabs/ImportsourceTabs.php b/library/Director/Web/Tabs/ImportsourceTabs.php
new file mode 100644
index 0000000..74dedb3
--- /dev/null
+++ b/library/Director/Web/Tabs/ImportsourceTabs.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ImportsourceTabs extends Tabs
+{
+ use TranslationHelper;
+
+ protected $id;
+
+ public function __construct($id = null)
+ {
+ $this->id = $id;
+ $this->assemble();
+ }
+
+ public function activateMainWithPostfix($postfix)
+ {
+ $mainTab = 'index';
+ $tab = $this->get($mainTab);
+ $tab->setLabel($tab->getLabel() . ": $postfix");
+ $this->activate($mainTab);
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ if ($id = $this->id) {
+ $params = ['id' => $id];
+ $this->add('index', [
+ 'url' => 'director/importsource',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Import source'),
+ ])->add('modifier', [
+ 'url' => 'director/importsource/modifier',
+ 'urlParams' => ['source_id' => $id],
+ 'label' => $this->translate('Modifiers'),
+ ])->add('history', [
+ 'url' => 'director/importsource/history',
+ 'urlParams' => $params,
+ 'label' => $this->translate('History'),
+ ])->add('preview', [
+ 'url' => 'director/importsource/preview',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Preview'),
+ ]);
+ } else {
+ $this->add('add', [
+ 'url' => 'director/importsource/add',
+ 'label' => $this->translate('New import source'),
+ ])->activate('add');
+ }
+ }
+}
diff --git a/library/Director/Web/Tabs/InfraTabs.php b/library/Director/Web/Tabs/InfraTabs.php
new file mode 100644
index 0000000..8a65c4e
--- /dev/null
+++ b/library/Director/Web/Tabs/InfraTabs.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Authentication\Auth;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class InfraTabs extends Tabs
+{
+ use TranslationHelper;
+
+ /** @var Auth */
+ protected $auth;
+
+ public function __construct(Auth $auth)
+ {
+ $this->auth = $auth;
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ $auth = $this->auth;
+
+ if ($auth->hasPermission('director/audit')) {
+ $this->add('activitylog', [
+ 'label' => $this->translate('Activity Log'),
+ 'url' => 'director/config/activities'
+ ]);
+ }
+
+ if ($auth->hasPermission('director/deploy')) {
+ $this->add('deploymentlog', [
+ 'label' => $this->translate('Deployments'),
+ 'url' => 'director/config/deployments'
+ ]);
+ }
+
+ if ($auth->hasPermission('director/admin')) {
+ $this->add('infrastructure', [
+ 'label' => $this->translate('Infrastructure'),
+ 'url' => 'director/dashboard',
+ 'urlParams' => ['name' => 'infrastructure']
+ ]);
+ }
+ }
+}
diff --git a/library/Director/Web/Tabs/MainTabs.php b/library/Director/Web/Tabs/MainTabs.php
new file mode 100644
index 0000000..5ea2e9b
--- /dev/null
+++ b/library/Director/Web/Tabs/MainTabs.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Web\Widget\Daemon\BackgroundDaemonState;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Health;
+use Icinga\Module\Director\Web\Widget\HealthCheckPluginOutput;
+
+class MainTabs extends Tabs
+{
+ use TranslationHelper;
+
+ protected $auth;
+
+ protected $dbResourceName;
+
+ public function __construct(Auth $auth, $dbResourceName)
+ {
+ $this->auth = $auth;
+ $this->dbResourceName = $dbResourceName;
+ $this->add('main', [
+ 'label' => $this->translate('Overview'),
+ 'url' => 'director'
+ ]);
+ if ($this->auth->hasPermission('director/admin')) {
+ $this->add('health', [
+ 'label' => $this->translate('Health'),
+ 'url' => 'director/health'
+ ])->add('daemon', [
+ 'label' => $this->translate('Daemon'),
+ 'url' => 'director/daemon'
+ ]);
+ }
+ }
+
+ public function render()
+ {
+ if ($this->auth->hasPermission('director/admin')) {
+ if ($this->getActiveName() !== 'health') {
+ $state = $this->getHealthState();
+ if ($state->isProblem()) {
+ $this->get('health')->setTagParams([
+ 'class' => 'state-' . strtolower($state->getName())
+ ]);
+ }
+ }
+
+ if ($this->getActiveName() !== 'daemon') {
+ try {
+ $daemon = new BackgroundDaemonState(Db::fromResourceName($this->dbResourceName));
+ if ($daemon->isRunning()) {
+ $state = 'ok';
+ } else {
+ $state = 'critical';
+ }
+ } catch (\Exception $e) {
+ $state = 'unknown';
+ }
+ if ($state !== 'ok') {
+ $this->get('daemon')->setTagParams([
+ 'class' => 'state-' . $state
+ ]);
+ }
+ }
+ }
+
+ return parent::render();
+ }
+
+ /**
+ * @return \Icinga\Module\Director\CheckPlugin\PluginState
+ */
+ protected function getHealthState()
+ {
+ $health = new Health();
+ $health->setDbResourceName($this->dbResourceName);
+ $output = new HealthCheckPluginOutput($health);
+
+ return $output->getState();
+ }
+}
diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php
new file mode 100644
index 0000000..cbd3f15
--- /dev/null
+++ b/library/Director/Web/Tabs/ObjectTabs.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Objects\IcingaObject;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ObjectTabs extends Tabs
+{
+ use TranslationHelper;
+
+ /** @var string */
+ private $type;
+
+ /** @var Auth */
+ private $auth;
+
+ /** @var IcingaObject $object */
+ private $object;
+
+ private $allowedExternals = [
+ 'apiuser',
+ 'endpoint'
+ ];
+
+ public function __construct($type, Auth $auth, IcingaObject $object = null)
+ {
+ $this->type = $type;
+ $this->auth = $auth;
+ $this->object = $object;
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ if (null === $this->object) {
+ $this->addTabsForNewObject();
+ } else {
+ $this->addTabsForExistingObject();
+ }
+ }
+
+ protected function addTabsForNewObject()
+ {
+ $type = $this->type;
+ $this->add('add', array(
+ 'url' => sprintf('director/%s/add', $type),
+ 'label' => sprintf($this->translate('Add %s'), ucfirst($type)),
+ ));
+ }
+
+ protected function addTabsForExistingObject()
+ {
+ $type = $this->type;
+ $auth = $this->auth;
+ $object = $this->object;
+ $params = $object->getUrlParams();
+
+ if (! $object->isExternal()
+ || in_array($object->getShortTableName(), $this->allowedExternals)
+ ) {
+ $this->add('modify', array(
+ 'url' => sprintf('director/%s', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate(ucfirst($type))
+ ));
+ }
+ if ($object->getShortTableName() === 'host') {
+ $this->add('services', [
+ 'url' => 'director/host/services',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Services')
+ ]);
+ }
+
+ if ($auth->hasPermission('director/showconfig')) {
+ if ($object->getShortTableName() !== 'service'
+ || $object->get('service_set_id') === null
+ ) {
+ $this->add('render', array(
+ 'url' => sprintf('director/%s/render', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('Preview'),
+ ));
+ }
+ }
+
+ if ($auth->hasPermission('director/audit')) {
+ $this->add('history', array(
+ 'url' => sprintf('director/%s/history', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('History')
+ ));
+ }
+
+ if ($auth->hasPermission('director/admin') && $this->hasFields()) {
+ $this->add('fields', array(
+ 'url' => sprintf('director/%s/fields', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('Fields')
+ ));
+ }
+
+ // TODO: remove table check once we resolve all group types
+ if ($object->isGroup() &&
+ ($object->getShortTableName() === 'hostgroup' || $object->getShortTableName() === 'servicegroup')
+ ) {
+ $this->add('membership', [
+ 'url' => sprintf('director/%s/membership', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('Members')
+ ]);
+ }
+
+ if ($object->supportsRanges()) {
+ $this->add('ranges', [
+ 'url' => "director/${type}/ranges",
+ 'urlParams' => $params,
+ 'label' => $this->translate('Ranges')
+ ]);
+ }
+
+ if ($object->getShortTableName() === 'endpoint'
+ && $object->get('apiuser_id')
+ ) {
+ $this->add('inspect', [
+ 'url' => 'director/inspect/types',
+ 'urlParams' => ['endpoint' => $object->getObjectName()],
+ 'label' => $this->translate('Inspect')
+ ]);
+ $this->add('packages', [
+ 'url' => 'director/inspect/packages',
+ 'urlParams' => ['endpoint' => $object->getObjectName()],
+ 'label' => $this->translate('Packages')
+ ]);
+ }
+
+ if ($object->getShortTableName() === 'host' && $auth->hasPermission('director/hosts')) {
+ $this->add('agent', [
+ 'url' => 'director/host/agent',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Agent')
+ ]);
+ }
+ }
+
+ protected function hasFields()
+ {
+ if (! ($object = $this->object)) {
+ return false;
+ }
+
+ return $object->hasBeenLoadedFromDb()
+ && $object->supportsFields()
+ && ($object->isTemplate() || $this->type === 'command');
+ }
+}
diff --git a/library/Director/Web/Tabs/ObjectsTabs.php b/library/Director/Web/Tabs/ObjectsTabs.php
new file mode 100644
index 0000000..4f9e5a8
--- /dev/null
+++ b/library/Director/Web/Tabs/ObjectsTabs.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Objects\IcingaObject;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ObjectsTabs extends Tabs
+{
+ use TranslationHelper;
+
+ public function __construct($type, Auth $auth, $typeUrl)
+ {
+ $object = IcingaObject::createByType($type);
+ if ($object->isGroup()) {
+ $object = IcingaObject::createByType(substr($typeUrl, 0, -5));
+ }
+ $shortName = $object->getShortTableName();
+
+ $plType = strtolower(preg_replace('/cys$/', 'cies', $shortName . 's'));
+ $plType = str_replace('_', '-', $plType);
+ if ($auth->hasPermission("director/${plType}")) {
+ $this->add('index', array(
+ 'url' => sprintf('director/%s', $plType),
+ 'label' => $this->translate(ucfirst($plType)),
+ ));
+ }
+
+ if ($object->getShortTableName() === 'command') {
+ $this->add('external', array(
+ 'url' => sprintf('director/%s', strtolower($plType)),
+ 'urlParams' => ['type' => 'external_object'],
+ 'label' => $this->translate('External'),
+ ));
+ }
+
+ if ($auth->hasPermission('director/admin') || (
+ $object->getShortTableName() === 'notification'
+ && $auth->hasPermission('director/notifications')
+ ) || (
+ $object->getShortTableName() === 'scheduled_downtime'
+ && $auth->hasPermission('director/scheduled-downtimes')
+ )) {
+ if ($object->supportsApplyRules()) {
+ $this->add('applyrules', array(
+ 'url' => sprintf('director/%s/applyrules', $plType),
+ 'label' => $this->translate('Apply')
+ ));
+ }
+ }
+
+ if ($auth->hasPermission('director/admin') && $type !== 'zone') {
+ if ($object->supportsImports()) {
+ $this->add('templates', array(
+ 'url' => sprintf('director/%s/templates', $plType),
+ 'label' => $this->translate('Templates'),
+ ));
+ }
+
+ if ($object->supportsGroups()) {
+ $this->add('groups', array(
+ 'url' => sprintf('director/%sgroups', $typeUrl),
+ 'label' => $this->translate('Groups')
+ ));
+ }
+ }
+
+ if ($auth->hasPermission('director/admin')) {
+ if ($object->supportsChoices()) {
+ $this->add('choices', array(
+ 'url' => sprintf('director/templatechoices/%s', $shortName),
+ 'label' => $this->translate('Choices')
+ ));
+ }
+ }
+ if ($object->supportsSets() && $auth->hasPermission("director/${typeUrl}sets")) {
+ $this->add('sets', array(
+ 'url' => sprintf('director/%s/sets', $plType),
+ 'label' => $this->translate('Sets')
+ ));
+ }
+ }
+}
diff --git a/library/Director/Web/Tabs/SyncRuleTabs.php b/library/Director/Web/Tabs/SyncRuleTabs.php
new file mode 100644
index 0000000..d64ff81
--- /dev/null
+++ b/library/Director/Web/Tabs/SyncRuleTabs.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Module\Director\Objects\SyncRule;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class SyncRuleTabs extends Tabs
+{
+ use TranslationHelper;
+
+ protected $rule;
+
+ public function __construct(SyncRule $rule = null)
+ {
+ $this->rule = $rule;
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ if ($this->rule) {
+ $id = $this->rule->get('id');
+ $this->add('show', [
+ 'url' => 'director/syncrule',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Sync rule'),
+ ])->add('preview', [
+ 'url' => 'director/syncrule/preview',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Preview'),
+ ])->add('edit', [
+ 'url' => 'director/syncrule/edit',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Modify'),
+ ])->add('property', [
+ 'label' => $this->translate('Properties'),
+ 'url' => 'director/syncrule/property',
+ 'urlParams' => ['rule_id' => $id]
+ ])->add('history', [
+ 'label' => $this->translate('History'),
+ 'url' => 'director/syncrule/history',
+ 'urlParams' => ['id' => $id]
+ ]);
+ } else {
+ $this->add('add', [
+ 'url' => 'director/syncrule/add',
+ 'label' => $this->translate('Sync rule'),
+ ]);
+ }
+ }
+}
diff --git a/library/Director/Web/Tree/InspectTreeRenderer.php b/library/Director/Web/Tree/InspectTreeRenderer.php
new file mode 100644
index 0000000..54a177f
--- /dev/null
+++ b/library/Director/Web/Tree/InspectTreeRenderer.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tree;
+
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+
+class InspectTreeRenderer extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = [
+ 'class' => 'tree',
+ 'data-base-target' => '_next',
+ ];
+
+ protected $tree;
+
+ /** @var IcingaEndpoint */
+ protected $endpoint;
+
+ public function __construct(IcingaEndpoint $endpoint)
+ {
+ $this->endpoint = $endpoint;
+ }
+
+ protected function getNodes()
+ {
+ $rootNodes = array();
+ $types = $this->endpoint->api()->getTypes();
+ foreach ($types as $name => $type) {
+ if (property_exists($type, 'base')) {
+ $base = $type->base;
+ if (! property_exists($types[$base], 'children')) {
+ $types[$base]->children = array();
+ }
+
+ $types[$base]->children[$name] = $type;
+ } else {
+ $rootNodes[$name] = $type;
+ }
+ }
+
+ return $rootNodes;
+ }
+
+ public function assemble()
+ {
+ $this->add($this->renderNodes($this->getNodes()));
+ }
+
+ protected function renderNodes($nodes, $showLinks = false, $level = 0)
+ {
+ $result = [];
+ foreach ($nodes as $child) {
+ $result[] = $this->renderNode($child, $showLinks, $level + 1);
+ }
+
+ if ($level === 0) {
+ return $result;
+ } else {
+ return Html::tag('ul', null, $result);
+ }
+ }
+
+ protected function renderNode($node, $forceLinks = false, $level = 0)
+ {
+ $name = $node->name;
+ $showLinks = $forceLinks || $name === 'ConfigObject';
+ $hasChildren = property_exists($node, 'children');
+ $li = Html::tag('li');
+ if (! $hasChildren) {
+ $li->getAttributes()->add('class', 'collapsed');
+ }
+
+ if ($hasChildren) {
+ $li->add(Html::tag('span', ['class' => 'handle']));
+ }
+
+ $class = $node->abstract ? 'icon-sitemap' : 'icon-doc-text';
+ $li->add(Link::create($name, 'director/inspect/type', [
+ 'endpoint' => $this->endpoint->getObjectName(),
+ 'type' => $name
+ ], ['class' => $class]));
+
+ if ($hasChildren) {
+ $li->add($this->renderNodes($node->children, $showLinks, $level + 1));
+ }
+
+ return $li;
+ }
+}
diff --git a/library/Director/Web/Tree/TemplateTreeRenderer.php b/library/Director/Web/Tree/TemplateTreeRenderer.php
new file mode 100644
index 0000000..e238ded
--- /dev/null
+++ b/library/Director/Web/Tree/TemplateTreeRenderer.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tree;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Resolver\TemplateTree;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+
+class TemplateTreeRenderer extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = [
+ 'class' => 'tree',
+ 'data-base-target' => '_next',
+ ];
+
+ protected $tree;
+
+ public function __construct(TemplateTree $tree)
+ {
+ $this->tree = $tree;
+ }
+
+ public static function showType($type, ControlsAndContent $controller, Db $db)
+ {
+ $controller->content()->add(
+ new static(new TemplateTree($type, $db))
+ );
+ }
+
+ public function renderContent()
+ {
+ $this->add(
+ $this->dumpTree(
+ array(
+ 'name' => $this->translate('Templates'),
+ 'children' => $this->tree->getTree()
+ )
+ )
+ );
+
+ return parent::renderContent();
+ }
+
+ protected function dumpTree($tree, $level = 0)
+ {
+ $hasChildren = ! empty($tree['children']);
+ $type = $this->tree->getType();
+
+ $li = Html::tag('li');
+ if (! $hasChildren) {
+ $li->getAttributes()->add('class', 'collapsed');
+ }
+
+ if ($hasChildren) {
+ $li->add(Html::tag('span', ['class' => 'handle']));
+ }
+
+ if ($level === 0) {
+ $li->add(Html::tag('a', [
+ 'name' => $tree['name'],
+ 'class' => 'icon-globe'
+ ], $tree['name']));
+ } else {
+ $li->add(Link::create(
+ $tree['name'],
+ "director/${type}template/usage",
+ array('name' => $tree['name']),
+ array('class' => 'icon-' .$type)
+ ));
+ }
+
+ if ($hasChildren) {
+ $li->add(
+ $ul = Html::tag('ul')
+ );
+ foreach ($tree['children'] as $child) {
+ $ul->add($this->dumpTree($child, $level + 1));
+ }
+ }
+
+ return $li;
+ }
+}
diff --git a/library/Director/Web/Widget/AbstractList.php b/library/Director/Web/Widget/AbstractList.php
new file mode 100644
index 0000000..ad1b9e3
--- /dev/null
+++ b/library/Director/Web/Widget/AbstractList.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+
+class AbstractList extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ /**
+ * AbstractList constructor.
+ * @param array $items
+ * @param null $attributes
+ */
+ public function __construct(array $items = [], $attributes = null)
+ {
+ foreach ($items as $item) {
+ $this->addItem($item);
+ }
+
+ if ($attributes !== null) {
+ $this->addAttributes($attributes);
+ }
+ }
+
+ /**
+ * @param Html|array|string $content
+ * @param Attributes|array $attributes
+ *
+ * @return $this
+ */
+ public function addItem($content, $attributes = null)
+ {
+ return $this->add(HtmlElement::create('li', $attributes, $content));
+ }
+}
diff --git a/library/Director/Web/Widget/ActivityLogInfo.php b/library/Director/Web/Widget/ActivityLogInfo.php
new file mode 100644
index 0000000..8454b26
--- /dev/null
+++ b/library/Director/Web/Widget/ActivityLogInfo.php
@@ -0,0 +1,634 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Json\JsonString;
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Forms\RestoreObjectForm;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ActivityLogInfo extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $defaultTab;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string */
+ protected $typeName;
+
+ /** @var string */
+ protected $name;
+
+ protected $entry;
+
+ protected $oldProperties;
+
+ protected $newProperties;
+
+ protected $oldObject;
+
+ /** @var Tabs */
+ protected $tabs;
+
+ /** @var int */
+ protected $id;
+
+ public function __construct(Db $db, $type = null, $name = null)
+ {
+ $this->db = $db;
+ if ($type !== null) {
+ $this->setType($type);
+ }
+ $this->name = $name;
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+ $this->typeName = $this->translate(
+ ucfirst(preg_replace('/^icinga_/', '', $type)) // really?
+ );
+
+ return $this;
+ }
+
+ /**
+ * @param Url $url
+ * @return HtmlElement
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function getPagination(Url $url)
+ {
+ /** @var Url $url */
+ $url = $url->without('checksum')->without('show');
+ $div = Html::tag('div', [
+ 'class' => 'pagination-control',
+ 'style' => 'float: right; width: 5em'
+ ]);
+
+ $ul = Html::tag('ul', ['class' => 'nav tab-nav']);
+ $li = Html::tag('li', ['class' => 'nav-item']);
+ $ul->add($li);
+ $neighbors = $this->getNeighbors();
+ $iconLeft = new Icon('angle-double-left');
+ $iconRight = new Icon('angle-double-right');
+ if ($neighbors->prev) {
+ $li->add(new Link($iconLeft, $url->with('id', $neighbors->prev)));
+ } else {
+ $li->add(Html::tag('span', ['class' => 'disabled'], $iconLeft));
+ }
+
+ $li = Html::tag('li', ['class' => 'nav-item']);
+ $ul->add($li);
+ if ($neighbors->next) {
+ $li->add(new Link($iconRight, $url->with('id', $neighbors->next)));
+ } else {
+ $li->add(Html::tag('span', ['class' => 'disabled'], $iconRight));
+ }
+
+ return $div->add($ul);
+ }
+
+ /**
+ * @param $tabName
+ * @return $this
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function showTab($tabName)
+ {
+ if ($tabName === null) {
+ $tabName = $this->defaultTab;
+ }
+
+ $this->getTabs()->activate($tabName);
+ $this->add($this->getInfoTable());
+ if ($tabName === 'old') {
+ // $title = sprintf('%s former config', $this->entry->object_name);
+ $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->emptyConfig());
+ } elseif ($tabName === 'new') {
+ // $title = sprintf('%s new config', $this->entry->object_name);
+ $diffs = IcingaConfigDiff::getDiffs($this->emptyConfig(), $this->newConfig());
+ } else {
+ $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->newConfig());
+ }
+
+ $this->addDiffs($diffs);
+
+ return $this;
+ }
+
+ protected function emptyConfig()
+ {
+ return new IcingaConfig($this->db);
+ }
+
+ /**
+ * @param $diffs
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function addDiffs($diffs)
+ {
+ foreach ($diffs as $file => $diff) {
+ $this->add(Html::tag('h3', null, $file))->add($diff);
+ }
+ }
+
+ /**
+ * @return RestoreObjectForm
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function getRestoreForm()
+ {
+ return RestoreObjectForm::load()
+ ->setDb($this->db)
+ ->setObject($this->oldObject())
+ ->handleRequest();
+ }
+
+ public function setChecksum($checksum)
+ {
+ if ($checksum !== null) {
+ $this->entry = $this->db->fetchActivityLogEntry($checksum);
+ $this->id = (int) $this->entry->id;
+ }
+
+ return $this;
+ }
+
+ public function setId($id)
+ {
+ if ($id !== null) {
+ $this->entry = $this->db->fetchActivityLogEntryById($id);
+ $this->id = (int) $id;
+ }
+
+ return $this;
+ }
+
+ public function getNeighbors()
+ {
+ return $this->db->getActivitylogNeighbors(
+ $this->id,
+ $this->type,
+ $this->name
+ );
+ }
+
+ public function getCurrentObject()
+ {
+ return IcingaObject::loadByType(
+ $this->type,
+ $this->name,
+ $this->db
+ );
+ }
+
+ /**
+ * @return bool
+ * @deprecated No longer used?
+ */
+ public function objectStillExists()
+ {
+ return IcingaObject::existsByType(
+ $this->type,
+ $this->objectKey(),
+ $this->db
+ );
+ }
+
+ protected function oldProperties()
+ {
+ if ($this->oldProperties === null) {
+ if (property_exists($this->entry, 'old_properties')) {
+ $this->oldProperties = JsonString::decodeOptional($this->entry->old_properties);
+ }
+ if ($this->oldProperties === null) {
+ $this->oldProperties = new \stdClass;
+ }
+ }
+
+ return $this->oldProperties;
+ }
+
+ protected function newProperties()
+ {
+ if ($this->newProperties === null) {
+ if (property_exists($this->entry, 'new_properties')) {
+ $this->newProperties = JsonString::decodeOptional($this->entry->new_properties);
+ }
+ if ($this->newProperties === null) {
+ $this->newProperties = new \stdClass;
+ }
+ }
+
+ return $this->newProperties;
+ }
+
+ protected function getEntryProperty($key)
+ {
+ $entry = $this->entry;
+
+ if (property_exists($entry, $key)) {
+ return $entry->{$key};
+ } elseif (property_exists($this->newProperties(), $key)) {
+ return $this->newProperties->{$key};
+ } elseif (property_exists($this->oldProperties(), $key)) {
+ return $this->oldProperties->{$key};
+ } else {
+ return null;
+ }
+ }
+
+ protected function objectLinkParams()
+ {
+ $entry = $this->entry;
+
+ $params = ['name' => $entry->object_name];
+
+ if ($entry->object_type === 'icinga_service') {
+ if (($set = $this->getEntryProperty('service_set')) !== null) {
+ $params['set'] = $set;
+ return $params;
+ } elseif (($host = $this->getEntryProperty('host')) !== null) {
+ $params['host'] = $host;
+ return $params;
+ } else {
+ return $params;
+ }
+ } elseif ($entry->object_type === 'icinga_service_set') {
+ return $params;
+ } else {
+ return $params;
+ }
+ }
+
+ protected function getActionExtraHtml()
+ {
+ $entry = $this->entry;
+
+ $info = '';
+ $host = null;
+
+ if ($entry->object_type === 'icinga_service') {
+ if (($set = $this->getEntryProperty('service_set')) !== null) {
+ $info = Html::sprintf(
+ '%s "%s"',
+ $this->translate('on service set'),
+ Link::create(
+ $set,
+ 'director/serviceset',
+ ['name' => $set],
+ ['data-base-target' => '_next']
+ )
+ );
+ } else {
+ $host = $this->getEntryProperty('host');
+ }
+ } elseif ($entry->object_type === 'icinga_service_set') {
+ $host = $this->getEntryProperty('host');
+ }
+
+ if ($host !== null) {
+ $info = Html::sprintf(
+ '%s "%s"',
+ $this->translate('on host'),
+ Link::create(
+ $host,
+ 'director/host',
+ ['name' => $host],
+ ['data-base-target' => '_next']
+ )
+ );
+ }
+
+ return $info;
+ }
+
+ /**
+ * @return array
+ * @deprecated No longer used?
+ */
+ protected function objectKey()
+ {
+ $entry = $this->entry;
+ if ($entry->object_type === 'icinga_service' || $entry->object_type === 'icinga_service_set') {
+ // TODO: this is not correct. Activity needs to get (multi) key support
+ return ['name' => $entry->object_name];
+ }
+
+ return $entry->object_name;
+ }
+
+ /**
+ * @param Url|null $url
+ * @return Tabs
+ */
+ public function getTabs(Url $url = null)
+ {
+ if ($this->tabs === null) {
+ $this->tabs = $this->createTabs($url);
+ }
+
+ return $this->tabs;
+ }
+
+ /**
+ * @param Url $url
+ * @return Tabs
+ */
+ public function createTabs(Url $url)
+ {
+ $entry = $this->entry;
+ $tabs = new Tabs();
+ if ($entry->action_name === DirectorActivityLog::ACTION_MODIFY) {
+ $tabs->add('diff', [
+ 'label' => $this->translate('Diff'),
+ 'url' => $url->without('show')->with('id', $entry->id)
+ ]);
+
+ $this->defaultTab = 'diff';
+ }
+
+ if (in_array($entry->action_name, [
+ DirectorActivityLog::ACTION_CREATE,
+ DirectorActivityLog::ACTION_MODIFY,
+ ])) {
+ $tabs->add('new', [
+ 'label' => $this->translate('New object'),
+ 'url' => $url->with(['id' => $entry->id, 'show' => 'new'])
+ ]);
+
+ if ($this->defaultTab === null) {
+ $this->defaultTab = 'new';
+ }
+ }
+
+ if (in_array($entry->action_name, [
+ DirectorActivityLog::ACTION_DELETE,
+ DirectorActivityLog::ACTION_MODIFY,
+ ])) {
+ $tabs->add('old', [
+ 'label' => $this->translate('Former object'),
+ 'url' => $url->with(['id' => $entry->id, 'show' => 'old'])
+ ]);
+
+ if ($this->defaultTab === null) {
+ $this->defaultTab = 'old';
+ }
+ }
+
+ return $tabs;
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function oldObject()
+ {
+ if ($this->oldObject === null) {
+ $this->oldObject = $this->createObject(
+ $this->entry->object_type,
+ $this->entry->old_properties
+ );
+ }
+
+ return $this->oldObject;
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function newObject()
+ {
+ return $this->createObject(
+ $this->entry->object_type,
+ $this->entry->new_properties
+ );
+ }
+
+ protected function objectToConfig(IcingaObject $object)
+ {
+ if ($object instanceof IcingaService) {
+ return $this->previewService($object);
+ } else {
+ return $object->toSingleIcingaConfig();
+ }
+ }
+
+ protected function previewService(IcingaService $service)
+ {
+ if (($set = $service->get('service_set')) !== null) {
+ // simulate rendering of service in set
+ $set = IcingaServiceSet::load($set, $this->db);
+
+ $service->set('service_set_id', null);
+ if (($assign = $set->get('assign_filter')) !== null) {
+ $service->set('object_type', 'apply');
+ $service->set('assign_filter', $assign);
+ }
+ }
+
+ return $service->toSingleIcingaConfig();
+ }
+
+ /**
+ * @return IcingaConfig
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function newConfig()
+ {
+ return $this->objectToConfig($this->newObject());
+ }
+
+ /**
+ * @return IcingaConfig
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function oldConfig()
+ {
+ return $this->objectToConfig($this->oldObject());
+ }
+
+ protected function getLinkToObject()
+ {
+ // TODO: This logic is redundant and should be centralized
+ $entry = $this->entry;
+ $name = $entry->object_name;
+ $controller = preg_replace('/^icinga_/', '', $entry->object_type);
+
+ if ($controller === 'service_set') {
+ $controller = 'serviceset';
+ } elseif ($controller === 'scheduled_downtime') {
+ $controller = 'scheduled-downtime';
+ }
+
+ return Link::create(
+ $name,
+ 'director/' . $controller,
+ $this->objectLinkParams(),
+ ['data-base-target' => '_next']
+ );
+ }
+
+ /**
+ * @return NameValueTable
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function getInfoTable()
+ {
+ $entry = $this->entry;
+ $table = new NameValueTable();
+ $table->addNameValuePairs([
+ $this->translate('Author') => $entry->author,
+ $this->translate('Date') => DateFormatter::formatDateTime(
+ $entry->change_time_ts
+ ),
+
+ ]);
+ if (null === $this->name) {
+ $table->addNameValueRow(
+ $this->translate('Action'),
+ Html::sprintf(
+ '%s %s "%s" %s',
+ $entry->action_name,
+ $entry->object_type,
+ $this->getLinkToObject(),
+ $this->getActionExtraHtml()
+ )
+ );
+ } else {
+ $table->addNameValueRow(
+ $this->translate('Action'),
+ $entry->action_name
+ );
+ }
+
+ if ($comment = $this->getOptionalRangeComment()) {
+ $table->addNameValueRow(
+ $this->translate('Remark'),
+ $comment
+ );
+ }
+
+ if ($this->hasBeenEnabled()) {
+ $table->addNameValueRow(
+ $this->translate('Rendering'),
+ $this->translate('This object has been enabled')
+ );
+ } elseif ($this->hasBeenDisabled()) {
+ $table->addNameValueRow(
+ $this->translate('Rendering'),
+ $this->translate('This object has been disabled')
+ );
+ }
+
+ $table->addNameValueRow(
+ $this->translate('Checksum'),
+ $entry->checksum
+ );
+ if ($this->entry->old_properties) {
+ $table->addNameValueRow(
+ $this->translate('Actions'),
+ $this->getRestoreForm()
+ );
+ }
+
+ return $table;
+ }
+
+ public function hasBeenEnabled()
+ {
+ return false;
+ }
+
+ public function hasBeenDisabled()
+ {
+ return false;
+ }
+
+ /**
+ * @return string
+ * @throws ProgrammingError
+ */
+ public function getTitle()
+ {
+ switch ($this->entry->action_name) {
+ case DirectorActivityLog::ACTION_CREATE:
+ $msg = $this->translate('%s "%s" has been created');
+ break;
+ case DirectorActivityLog::ACTION_DELETE:
+ $msg = $this->translate('%s "%s" has been deleted');
+ break;
+ case DirectorActivityLog::ACTION_MODIFY:
+ $msg = $this->translate('%s "%s" has been modified');
+ break;
+ default:
+ throw new ProgrammingError(
+ 'Unable to deal with "%s" activity',
+ $this->entry->action_name
+ );
+ }
+
+ return sprintf($msg, $this->typeName, $this->entry->object_name);
+ }
+
+ protected function getOptionalRangeComment()
+ {
+ if ($this->id) {
+ $db = $this->db->getDbAdapter();
+ return $db->fetchOne(
+ $db->select()
+ ->from('director_activity_log_remark', 'remark')
+ ->where('first_related_activity <= ?', $this->id)
+ ->where('last_related_activity >= ?', $this->id)
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param $type
+ * @param $props
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function createObject($type, $props)
+ {
+ $props = json_decode($props);
+ $newProps = ['object_name' => $props->object_name];
+ if (property_exists($props, 'object_type')) {
+ $newProps['object_type'] = $props->object_type;
+ }
+
+ return IcingaObject::createByType(
+ $type,
+ $newProps,
+ $this->db
+ )->setProperties((array) $props);
+ }
+}
diff --git a/library/Director/Web/Widget/AdditionalTableActions.php b/library/Director/Web/Widget/AdditionalTableActions.php
new file mode 100644
index 0000000..978f399
--- /dev/null
+++ b/library/Director/Web/Widget/AdditionalTableActions.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Web\Table\FilterableByUsage;
+
+class AdditionalTableActions
+{
+ use TranslationHelper;
+
+ /** @var Auth */
+ protected $auth;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var ZfQueryBasedTable */
+ protected $table;
+
+ public function __construct(Auth $auth, Url $url, ZfQueryBasedTable $table)
+ {
+ $this->auth = $auth;
+ $this->url = $url;
+ $this->table = $table;
+ }
+
+ public function appendTo(HtmlDocument $parent)
+ {
+ $links = [];
+ if ($this->hasPermission('director/admin')) {
+ $links[] = $this->createDownloadJsonLink();
+ }
+ if ($this->hasPermission('director/showsql')) {
+ $links[] = $this->createShowSqlToggle();
+ }
+
+ if ($this->table instanceof FilterableByUsage) {
+ $parent->add($this->showUsageFilter($this->table));
+ }
+
+ if (! empty($links)) {
+ $parent->add($this->moreOptions($links));
+ }
+
+ return $this;
+ }
+
+ protected function createDownloadJsonLink()
+ {
+ return Link::create(
+ $this->translate('Download as JSON'),
+ $this->url->with('format', 'json'),
+ null,
+ ['target' => '_blank']
+ );
+ }
+
+ protected function createShowSqlToggle()
+ {
+ if ($this->url->getParam('format') === 'sql') {
+ $link = Link::create(
+ $this->translate('Hide SQL'),
+ $this->url->without('format')
+ );
+ } else {
+ $link = Link::create(
+ $this->translate('Show SQL'),
+ $this->url->with('format', 'sql')
+ );
+ }
+
+ return $link;
+ }
+
+ protected function showUsageFilter(FilterableByUsage $table)
+ {
+ $active = $this->url->getParam('usage', 'all');
+ $links = [
+ Link::create($this->translate('all'), $this->url->without('usage')),
+ Link::create($this->translate('used'), $this->url->with('usage', 'used')),
+ Link::create($this->translate('unused'), $this->url->with('usage', 'unused')),
+ ];
+
+ if ($active === 'used') {
+ $table->showOnlyUsed();
+ } elseif ($active === 'unused') {
+ $table->showOnlyUnUsed();
+ }
+
+ $options = $this->ul(
+ $this->li([
+ Link::create(
+ sprintf($this->translate('Usage (%s)'), $active),
+ '#',
+ null,
+ [
+ 'class' => 'icon-sitemap'
+ ]
+ ),
+ $subUl = Html::tag('ul')
+ ]),
+ ['class' => 'nav']
+ );
+
+ foreach ($links as $link) {
+ $subUl->add($this->li($link));
+ }
+
+ return $options;
+ }
+
+ protected function moreOptions($links)
+ {
+ $options = $this->ul(
+ $this->li([
+ // TODO: extend link for dropdown-toggle from Web 2, doesn't
+ // seem to work: [..], null, ['class' => 'dropdown-toggle']
+ Link::create(Icon::create('down-open'), '#'),
+ $subUl = Html::tag('ul')
+ ]),
+ ['class' => 'nav']
+ );
+
+ foreach ($links as $link) {
+ $subUl->add($this->li($link));
+ }
+
+ return $options;
+ }
+
+ protected function ulLi($content)
+ {
+ return $this->ul($this->li($content));
+ }
+
+ protected function ul($content, $attributes = null)
+ {
+ return Html::tag('ul', $attributes, $content);
+ }
+
+ protected function li($content)
+ {
+ return Html::tag('li', null, $content);
+ }
+
+ protected function hasPermission($permission)
+ {
+ return $this->auth->hasPermission($permission);
+ }
+}
diff --git a/library/Director/Web/Widget/BackgroundDaemonDetails.php b/library/Director/Web/Widget/BackgroundDaemonDetails.php
new file mode 100644
index 0000000..b4c33dd
--- /dev/null
+++ b/library/Director/Web/Widget/BackgroundDaemonDetails.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Daemon\RunningDaemonInfo;
+use Icinga\Util\Format;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\Table;
+
+class BackgroundDaemonDetails extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'div';
+
+ /** @var RunningDaemonInfo */
+ protected $info;
+
+ /** @var \stdClass TODO: get rid of this */
+ protected $daemon;
+
+ public function __construct(RunningDaemonInfo $info, $daemon)
+ {
+ $this->info = $info;
+ $this->daemon = $daemon;
+ }
+
+ protected function assemble()
+ {
+ $info = $this->info;
+ if ($info->hasBeenStopped()) {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate(
+ 'Daemon has been stopped %s, was running with PID %s as %s@%s'
+ ),
+ // $info->getHexUuid(),
+ $this->timeAgo($info->getTimestampStopped() / 1000),
+ Html::tag('strong', (string) $info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn())
+ )));
+ } elseif ($info->isOutdated()) {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate(
+ 'Daemon keep-alive is outdated, was last seen running with PID %s as %s@%s %s'
+ ),
+ // $info->getHexUuid(),
+ Html::tag('strong', (string) $info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn()),
+ $this->timeAgo($info->getLastUpdate() / 1000)
+ )));
+ } else {
+ $this->add(Hint::ok(Html::sprintf(
+ $this->translate(
+ 'Daemon is running with PID %s as %s@%s, last refresh happened %s'
+ ),
+ // $info->getHexUuid(),
+ Html::tag('strong', (string)$info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn()),
+ $this->timeAgo($info->getLastUpdate() / 1000)
+ )));
+ $details = new NameValueTable();
+ $details->addNameValuePairs([
+ $this->translate('Startup Time') => DateFormatter::formatDateTime($info->getTimestampStarted() / 1000),
+ $this->translate('PID') => $info->getPid(),
+ $this->translate('Username') => $info->getUsername(),
+ $this->translate('FQDN') => $info->getFqdn(),
+ $this->translate('Running with systemd') => $info->isRunningWithSystemd()
+ ? $this->translate('yes')
+ : $this->translate('no'),
+ $this->translate('Binary') => $info->getBinaryPath()
+ . ($info->binaryRealpathDiffers() ? ' -> ' . $info->getBinaryRealpath() : ''),
+ $this->translate('PHP Binary') => $info->getPhpBinaryPath()
+ . ($info->phpBinaryRealpathDiffers() ? ' -> ' . $info->getPhpBinaryRealpath() : ''),
+ $this->translate('PHP Version') => $info->getPhpVersion(),
+ $this->translate('PHP Integer') => $info->has64bitIntegers()
+ ? '64bit'
+ : Html::sprintf(
+ '%sbit (%s)',
+ $info->getPhpIntegerSize() * 8,
+ Html::tag('span', ['class' => 'error'], $this->translate('unsupported'))
+ ),
+ ]);
+ $this->add($details);
+
+ $this->add(Html::tag('h2', $this->translate('Process List')));
+ if (\is_string($this->daemon->process_info)) {
+ // from DB:
+ $processes = \json_decode($this->daemon->process_info);
+ } else {
+ // via RPC:
+ $processes = $this->daemon->process_info;
+ }
+ $table = new Table();
+ $table->add(Html::tag('thead', Html::tag('tr', Html::wrapEach([
+ 'PID',
+ 'Command',
+ 'Memory'
+ ], 'th'))));
+ $table->setAttribute('class', 'common-table');
+ foreach ($processes as $pid => $process) {
+ $table->add($table::row([
+ [
+ Icon::create($process->running ? 'ok' : 'warning-empty'),
+ ' ',
+ $pid
+ ],
+ Html::tag('pre', $process->command),
+ $process->memory === false ? 'n/a' : Format::bytes($process->memory->rss)
+ ]));
+ }
+ $this->add($table);
+ }
+ }
+
+ protected function timeAgo($time)
+ {
+ return Html::tag('span', [
+ 'class' => 'time-ago',
+ 'title' => DateFormatter::formatDateTime($time)
+ ], DateFormatter::timeAgo($time));
+ }
+}
diff --git a/library/Director/Web/Widget/BranchedObjectHint.php b/library/Director/Web/Widget/BranchedObjectHint.php
new file mode 100644
index 0000000..ec16094
--- /dev/null
+++ b/library/Director/Web/Widget/BranchedObjectHint.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchedObject;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+
+class BranchedObjectHint extends HtmlDocument
+{
+ use TranslationHelper;
+
+ public function __construct(Branch $branch, Auth $auth, BranchedObject $object = null)
+ {
+ if (! $branch->isBranch()) {
+ return;
+ }
+ $hook = Branch::requireHook();
+
+ $name = $branch->getName();
+ if (substr($name, 0, 1) === '/') {
+ $label = $this->translate('this configuration branch');
+ } else {
+ $label = $name;
+ }
+ $link = $hook->linkToBranch($branch, $auth, $label);
+ if ($object === null) {
+ $this->add(Hint::info(Html::sprintf($this->translate(
+ 'This object will be created in %s. It will not be part of any deployment'
+ . ' unless being merged'
+ ), $link)));
+ return;
+ }
+
+ if (! $object->hasBeenTouchedByBranch()) {
+ $this->add(Hint::info(Html::sprintf($this->translate(
+ 'Your changes will be stored in %s. The\'ll not be part of any deployment'
+ . ' unless being merged'
+ ), $link)));
+ return;
+ }
+
+ if ($object->hasBeenDeletedByBranch()) {
+ throw new NotFoundError('No such object available');
+ // Alternative, requires hiding other actions:
+ // $this->add(Hint::info(Html::sprintf(
+ // $this->translate('This object has been deleted in %s'),
+ // $link
+ // )));
+ } elseif ($object->hasBeenCreatedByBranch()) {
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('This object has been created in %s'),
+ $link
+ )));
+ } else {
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('This object has modifications visible only in %s'),
+ // TODO: Also link to object modifications
+ // $hook->linkToBranchedObject($this->translate('modifications'), $branch, $object, $auth),
+ $link
+ )));
+ }
+ }
+}
diff --git a/library/Director/Web/Widget/BranchedObjectsHint.php b/library/Director/Web/Widget/BranchedObjectsHint.php
new file mode 100644
index 0000000..d689178
--- /dev/null
+++ b/library/Director/Web/Widget/BranchedObjectsHint.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+
+class BranchedObjectsHint extends HtmlDocument
+{
+ use TranslationHelper;
+
+ public function __construct(Branch $branch, Auth $auth)
+ {
+ if (! $branch->isBranch()) {
+ return;
+ }
+ $hook = Branch::requireHook();
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('Showing a branched view, with potential changes being visible only in this %s'),
+ $hook->linkToBranch($branch, $auth, $this->translate('configuration branch'))
+ )));
+ }
+}
diff --git a/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php
new file mode 100644
index 0000000..03e76b2
--- /dev/null
+++ b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget\Daemon;
+
+use Icinga\Module\Director\Daemon\RunningDaemonInfo;
+use Icinga\Module\Director\Db;
+
+class BackgroundDaemonState
+{
+ protected $db;
+
+ /** @var RunningDaemonInfo[] */
+ protected $instances;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ public function isRunning()
+ {
+ foreach ($this->getInstances() as $instance) {
+ if ($instance->isRunning()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getInstances()
+ {
+ if ($this->instances === null) {
+ $this->instances = $this->fetchInfo();
+ }
+
+ return $this->instances;
+ }
+
+ /**
+ * @return RunningDaemonInfo[]
+ */
+ protected function fetchInfo()
+ {
+ $db = $this->db->getDbAdapter();
+ $daemons = $db->fetchAll(
+ $db->select()->from('director_daemon_info')->order('fqdn')->order('username')->order('pid')
+ );
+
+ $result = [];
+ foreach ($daemons as $info) {
+ $result[] = new RunningDaemonInfo($info);
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/Web/Widget/DeployedConfigInfoHeader.php b/library/Director/Web/Widget/DeployedConfigInfoHeader.php
new file mode 100644
index 0000000..0e841f3
--- /dev/null
+++ b/library/Director/Web/Widget/DeployedConfigInfoHeader.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Core\DeploymentApiInterface;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Forms\DeployConfigForm;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+
+class DeployedConfigInfoHeader extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ /** @var int */
+ protected $deploymentId;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var DeploymentApiInterface */
+ protected $api;
+
+ /** @var Branch */
+ protected $branch;
+
+ public function __construct(
+ IcingaConfig $config,
+ Db $db,
+ DeploymentApiInterface $api,
+ Branch $branch,
+ $deploymentId = null
+ ) {
+ $this->config = $config;
+ $this->db = $db;
+ $this->api = $api;
+ $this->branch = $branch;
+ if ($deploymentId) {
+ $this->deploymentId = (int) $deploymentId;
+ }
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Zend_Form_Exception
+ */
+ protected function assemble()
+ {
+ $config = $this->config;
+ if ($this->branch->isBranch()) {
+ $deployForm = null;
+ } else {
+ $deployForm = DeployConfigForm::load()
+ ->setDb($this->db)
+ ->setApi($this->api)
+ ->setChecksum($config->getHexChecksum())
+ ->setDeploymentId($this->deploymentId)
+ ->setAttrib('class', 'inline')
+ ->handleRequest();
+ }
+
+ $links = new NameValueTable();
+ $links->addNameValueRow(
+ $this->translate('Actions'),
+ [
+ $deployForm,
+ Html::tag('br'),
+ Link::create(
+ $this->translate('Last related activity'),
+ 'director/config/activity',
+ ['checksum' => $config->getLastActivityHexChecksum()],
+ ['class' => 'icon-clock', 'data-base-target' => '_next']
+ ),
+ Html::tag('br'),
+ Link::create(
+ $this->translate('Diff with other config'),
+ 'director/config/diff',
+ ['left' => $config->getHexChecksum()],
+ ['class' => 'icon-flapping', 'data-base-target' => '_self']
+ )
+ ]
+ )->addNameValueRow(
+ $this->translate('Statistics'),
+ sprintf(
+ $this->translate('%d files rendered in %0.2fs'),
+ count($config->getFiles()),
+ $config->getDuration() / 1000
+ )
+ );
+
+ $this->add($links);
+ }
+}
diff --git a/library/Director/Web/Widget/DeploymentInfo.php b/library/Director/Web/Widget/DeploymentInfo.php
new file mode 100644
index 0000000..110200f
--- /dev/null
+++ b/library/Director/Web/Widget/DeploymentInfo.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\HtmlDocument;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use Icinga\Module\Director\StartupLogRenderer;
+use Icinga\Util\Format;
+use Icinga\Web\Request;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class DeploymentInfo extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var DirectorDeploymentLog */
+ protected $deployment;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ /**
+ * DeploymentInfo constructor.
+ * @param DirectorDeploymentLog $deployment
+ */
+ public function __construct(DirectorDeploymentLog $deployment)
+ {
+ $this->deployment = $deployment;
+ if ($deployment->get('config_checksum') !== null) {
+ $this->config = IcingaConfig::load(
+ $deployment->get('config_checksum'),
+ $deployment->getConnection()
+ );
+ }
+ }
+
+ /**
+ * @param Auth $auth
+ * @param Request $request
+ * @return Tabs
+ */
+ public function getTabs(Auth $auth, Request $request)
+ {
+ $dep = $this->deployment;
+ $tabs = new Tabs();
+ $tabs->add('deployment', array(
+ 'label' => $this->translate('Deployment'),
+ 'url' => $request->getUrl()
+ ))->activate('deployment');
+
+ if ($dep->config_checksum !== null && $auth->hasPermission('director/showconfig')) {
+ $tabs->add('config', array(
+ 'label' => $this->translate('Config'),
+ 'url' => 'director/config/files',
+ 'urlParams' => array(
+ 'checksum' => $this->config->getHexChecksum(),
+ 'deployment_id' => $dep->id
+ )
+ ));
+ }
+
+ return $tabs;
+ }
+
+ protected function createInfoTable()
+ {
+ $dep = $this->deployment;
+ $table = new NameValueTable();
+ $table->addNameValuePairs([
+ $this->translate('Deployment time') => $dep->start_time,
+ $this->translate('Sent to') => $dep->peer_identity,
+ ]);
+ if ($this->config !== null) {
+ $table->addNameValuePairs([
+ $this->translate('Configuration') => $this->getConfigDetails(),
+ $this->translate('Duration') => $this->getDurationInfo(),
+ ]);
+ }
+ $table->addNameValuePairs([
+ $this->translate('Stage name') => $dep->stage_name,
+ $this->translate('Startup') => $this->getStartupInfo()
+ ]);
+
+ return $table;
+ }
+
+ protected function getDurationInfo()
+ {
+ return sprintf(
+ $this->translate('Rendered in %0.2fs, deployed in %0.2fs'),
+ $this->config->getDuration() / 1000,
+ $this->deployment->duration_dump / 1000
+ );
+ }
+
+ protected function getConfigDetails()
+ {
+ $cfg = $this->config;
+ $dep = $this->deployment;
+
+ return [
+ Link::create(
+ sprintf($this->translate('%d files'), $cfg->getFileCount()),
+ 'director/config/files',
+ [
+ 'checksum' => $cfg->getHexChecksum(),
+ 'deployment_id' => $dep->id
+ ]
+ ),
+ ', ',
+ sprintf(
+ $this->translate('%d objects, %d templates, %d apply rules'),
+ $cfg->getObjectCount(),
+ $cfg->getTemplateCount(),
+ $cfg->getApplyCount()
+ ),
+ ', ',
+ Format::bytes($cfg->getSize())
+ ];
+ }
+
+ protected function getStartupInfo()
+ {
+ $dep = $this->deployment;
+ if ($dep->startup_succeeded === null) {
+ if ($dep->stage_collected === null) {
+ return [$this->translate('Unknown, still waiting for config check outcome'), new Icon('spinner')];
+ } else {
+ return [$this->translate('Unknown, failed to collect related information'), new Icon('help')];
+ }
+ } elseif ($dep->startup_succeeded === 'y') {
+ return $this->colored('green', [$this->translate('Succeeded'), new Icon('ok')]);
+ } else {
+ return $this->colored('red', [$this->translate('Failed'), new Icon('cancel')]);
+ }
+ }
+
+ protected function colored($color, array $content)
+ {
+ return Html::tag('div', ['style' => "color: $color;"], $content)->setSeparator(' ');
+ }
+
+ public function render()
+ {
+ $this->add($this->createInfoTable());
+ if ($this->deployment->get('startup_succeeded') !== null) {
+ $this->addStartupLog();
+ }
+
+ return parent::render();
+ }
+
+ protected function addStartupLog()
+ {
+ $this->add(Html::tag('h2', null, $this->translate('Startup Log')));
+ $this->add(
+ Html::tag('pre', [
+ 'class' => 'logfile'
+ ], new StartupLogRenderer($this->deployment))
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/Documentation.php b/library/Director/Web/Widget/Documentation.php
new file mode 100644
index 0000000..8665e30
--- /dev/null
+++ b/library/Director/Web/Widget/Documentation.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use ipl\Html\Html;
+
+class Documentation
+{
+ use TranslationHelper;
+
+ /** @var ApplicationBootstrap */
+ protected $app;
+
+ /** @var Auth */
+ protected $auth;
+
+ public function __construct(ApplicationBootstrap $app, Auth $auth)
+ {
+ $this->app = $app;
+ $this->auth = $auth;
+ }
+
+ public static function link($label, $module, $chapter, $title = null)
+ {
+ $doc = new static(Icinga::app(), Auth::getInstance());
+ return $doc->getModuleLink($label, $module, $chapter, $title);
+ }
+
+ public function getModuleLink($label, $module, $chapter, $title = null)
+ {
+ if ($title !== null) {
+ $title = sprintf(
+ $this->translate('Click to read our documentation: %s'),
+ $title
+ );
+ }
+ $linkToGitHub = false;
+ $baseParams = [
+ 'class' => 'icon-book',
+ 'title' => $title,
+ ];
+ if ($this->hasAccessToDocumentationModule()) {
+ return Link::create(
+ $label,
+ $this->getDirectorDocumentationUrl($chapter),
+ null,
+ ['data-base-target' => '_next'] + $baseParams
+ );
+ }
+
+ $baseParams['target'] = '_blank';
+ if ($linkToGitHub) {
+ return Html::tag('a', [
+ 'href' => $this->githubDocumentationUrl($module, $chapter),
+ ] + $baseParams, $label);
+ }
+
+ return Html::tag('a', [
+ 'href' => $this->icingaDocumentationUrl($module, $chapter),
+ ] + $baseParams, $label);
+ }
+
+ protected function getDirectorDocumentationUrl($chapter)
+ {
+ return 'doc/module/director/chapter/'
+ . \preg_replace('/^\d+-/', '', \rawurlencode($chapter));
+ }
+
+ protected function githubDocumentationUrl($module, $chapter)
+ {
+ return sprintf(
+ "https://github.com/Icinga/icingaweb2-module-%s/blob/master/doc/%s.md",
+ \rawurlencode($module),
+ \rawurlencode($chapter)
+ );
+ }
+
+ protected function icingaDocumentationUrl($module, $chapter)
+ {
+ return sprintf(
+ 'https://icinga.com/docs/%s/latest/doc/%s/',
+ \rawurlencode($module),
+ \rawurlencode($chapter)
+ );
+ }
+
+ protected function hasAccessToDocumentationModule()
+ {
+ return $this->app->getModuleManager()->hasLoaded('doc')
+ && $this->auth->hasPermission('module/doc');
+ }
+}
diff --git a/library/Director/Web/Widget/HealthCheckPluginOutput.php b/library/Director/Web/Widget/HealthCheckPluginOutput.php
new file mode 100644
index 0000000..83ac102
--- /dev/null
+++ b/library/Director/Web/Widget/HealthCheckPluginOutput.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\CheckPlugin\PluginState;
+use Icinga\Module\Director\Health;
+
+class HealthCheckPluginOutput extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var Health */
+ protected $health;
+
+ /** @var PluginState */
+ protected $state;
+
+ public function __construct(Health $health)
+ {
+ $this->state = new PluginState('OK');
+ $this->health = $health;
+ $this->process();
+ }
+
+ protected function process()
+ {
+ $checks = $this->health->getAllChecks();
+
+ foreach ($checks as $check) {
+ $this->add([
+ $title = Html::tag('h1', $check->getName()),
+ $ul = Html::tag('ul', ['class' => 'health-check-result'])
+ ]);
+
+ $problems = $check->getProblemSummary();
+ if (! empty($problems)) {
+ $badges = Html::tag('span', ['class' => 'title-badges']);
+ foreach ($problems as $state => $count) {
+ $badges->add(Html::tag('span', [
+ 'class' => ['badge', 'state-' . strtolower($state)],
+ 'title' => sprintf(
+ $this->translate('%s: %d'),
+ $this->translate($state),
+ $count
+ ),
+ ], $count));
+ }
+ $title->add($badges);
+ }
+
+ foreach ($check->getResults() as $result) {
+ $state = $result->getState()->getName();
+ $ul->add(Html::tag('li', [
+ 'class' => 'state state-' . strtolower($state)
+ ], $this->highlightNames($result->getOutput()))->setSeparator(' '));
+ }
+ $this->state->raise($check->getState());
+ }
+ }
+
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ protected function colorizeState($state)
+ {
+ return Html::tag('span', ['class' => 'badge state-' . strtolower($state)], $state);
+ }
+
+ protected function highlightNames($string)
+ {
+ $string = Html::escape($string);
+ return new HtmlString(preg_replace_callback(
+ "/'([^']+)'/",
+ [$this, 'highlightName'],
+ $string
+ ));
+ }
+
+ protected function highlightName($match)
+ {
+ return '"' . Html::tag('strong', $match[1]) . '"';
+ }
+
+ protected function getColorized($match)
+ {
+ return $this->colorizeState($match[1]);
+ }
+}
diff --git a/library/Director/Web/Widget/IcingaConfigDiff.php b/library/Director/Web/Widget/IcingaConfigDiff.php
new file mode 100644
index 0000000..800f1d9
--- /dev/null
+++ b/library/Director/Web/Widget/IcingaConfigDiff.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Diff\HtmlRenderer\SideBySideDiff;
+use gipfl\Diff\PhpDiff;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\ValidHtml;
+
+class IcingaConfigDiff extends HtmlDocument
+{
+ public function __construct(IcingaConfig $left, IcingaConfig $right)
+ {
+ foreach (static::getDiffs($left, $right) as $filename => $diff) {
+ $this->add([
+ Html::tag('h3', $filename),
+ $diff
+ ]);
+ }
+ }
+
+ /**
+ * @param IcingaConfig $oldConfig
+ * @param IcingaConfig $newConfig
+ * @return ValidHtml[]
+ */
+ public static function getDiffs(IcingaConfig $oldConfig, IcingaConfig $newConfig)
+ {
+ $oldFileNames = $oldConfig->getFileNames();
+ $newFileNames = $newConfig->getFileNames();
+
+ $fileNames = array_merge($oldFileNames, $newFileNames);
+
+ $diffs = [];
+ foreach ($fileNames as $filename) {
+ if (in_array($filename, $oldFileNames)) {
+ $left = $oldConfig->getFile($filename)->getContent();
+ } else {
+ $left = '';
+ }
+
+ if (in_array($filename, $newFileNames)) {
+ $right = $newConfig->getFile($filename)->getContent();
+ } else {
+ $right = '';
+ }
+ if ($left === $right) {
+ continue;
+ }
+
+ $diffs[$filename] = new SideBySideDiff(new PhpDiff($left, $right));
+ }
+
+ return $diffs;
+ }
+}
diff --git a/library/Director/Web/Widget/IcingaObjectInspection.php b/library/Director/Web/Widget/IcingaObjectInspection.php
new file mode 100644
index 0000000..61f3567
--- /dev/null
+++ b/library/Director/Web/Widget/IcingaObjectInspection.php
@@ -0,0 +1,254 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\PlainObjectRenderer;
+use Icinga\Module\Director\Web\Table\DbHelper;
+use stdClass;
+
+class IcingaObjectInspection extends BaseHtmlElement
+{
+ use DbHelper;
+ use TranslationHelper;
+
+ protected $tag = 'div';
+
+ /** @var Db */
+ protected $db;
+
+ /** @var stdClass */
+ protected $object;
+
+ public function __construct(stdClass $object, Db $db)
+ {
+ $this->object = $object;
+ $this->db = $db;
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function assemble()
+ {
+ $attrs = $this->object->attrs;
+ if (isset($attrs->source_location)) {
+ $this->renderSourceLocation($attrs->source_location);
+ }
+ if (isset($attrs->last_check_result)) {
+ $this->renderLastCheckResult($attrs->last_check_result);
+ }
+
+ $this->renderObjectAttributes($attrs);
+ // $this->add(Html::tag('pre', null, PlainObjectRenderer::render($this->object)));
+ }
+
+ /**
+ * @param $result
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderLastCheckResult($result)
+ {
+ $this->add(Html::tag('h2', null, $this->translate('Last Check Result')));
+ $this->renderCheckResultDetails($result);
+ if (property_exists($result, 'command')) {
+ $this->renderExecutedCommand($result->command);
+ }
+ }
+
+ /**
+ * @param array|string $command
+ *
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderExecutedCommand($command)
+ {
+ if (is_array($command)) {
+ $command = implode(' ', array_map('escapeshellarg', $command));
+ }
+ $this->add([
+ Html::tag('h3', null, 'Executed Command'),
+ $this->formatConsole($command)
+ ]);
+ }
+
+ protected function renderCheckResultDetails($result)
+ {
+ }
+
+ /**
+ * @param $attrs
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderObjectAttributes($attrs)
+ {
+ $blacklist = [
+ 'last_check_result',
+ 'source_location',
+ 'templates',
+ ];
+
+ $linked = [
+ 'check_command',
+ 'groups',
+ ];
+
+ $info = new NameValueTable();
+ foreach ($attrs as $key => $value) {
+ if (in_array($key, $blacklist)) {
+ continue;
+ }
+ if ($key === 'groups') {
+ $info->addNameValueRow($key, $this->linkGroups($value));
+ } elseif (in_array($key, $linked)) {
+ $info->addNameValueRow($key, $this->renderLinkedObject($key, $value));
+ } else {
+ $info->addNameValueRow($key, PlainObjectRenderer::render($value));
+ }
+ }
+
+ $this->add([
+ Html::tag('h2', null, 'Object Properties'),
+ $info
+ ]);
+ }
+
+ /**
+ * @param $key
+ * @param $objectName
+ * @return Link|Link[]
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function renderLinkedObject($key, $objectName)
+ {
+ $keys = [
+ 'check_command' => ['CheckCommand', 'CheckCommands'],
+ 'event_command' => ['EventCommand', 'EventCommands'],
+ 'notification_command' => ['NotificationCommand', 'NotificationCommands'],
+ ];
+ $type = $keys[$key];
+
+ if ($key === 'groups') {
+ return $this->linkGroups($objectName);
+ } else {
+ $singular = $type[0];
+ $plural = $type[1];
+
+ return Link::create($objectName, 'director/inspect/object', [
+ 'type' => $singular,
+ 'plural' => $plural,
+ 'name' => $objectName
+ ]);
+ }
+ }
+
+ /**
+ * @param $groups
+ * @return Link[]
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkGroups($groups)
+ {
+ if ($groups === null) {
+ return [];
+ }
+
+ $singular = $this->object->type . 'Group';
+ $plural = $singular . "s";
+
+ $links = [];
+
+ foreach ($groups as $name) {
+ $links[] = Link::create($name, 'director/inspect/object', [
+ 'type' => $singular,
+ 'plural' => $plural,
+ 'name' => $name
+ ]);
+ }
+
+ return $links;
+ }
+
+ /**
+ * @param stdClass $source
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderSourceLocation(stdClass $source)
+ {
+ $findRelative = 'api/packages/director';
+ $this->add(Html::tag('h2')->add('Source Location'));
+ $pos = strpos($source->path, $findRelative);
+
+ if (false === $pos) {
+ $this->add(Html::tag('p', null, Html::sprintf(
+ 'The configuration for this object has not been rendered by'
+ . ' Icinga Director. You can find it on line %s in %s.',
+ Html::tag('strong', null, $source->first_line),
+ Html::tag('strong', null, $source->path)
+ )));
+ } else {
+ $relativePath = substr($source->path, $pos + strlen($findRelative) + 1);
+ $parts = explode('/', $relativePath);
+ $stageName = array_shift($parts);
+ $relativePath = implode('/', $parts);
+ $source->director_relative = $relativePath;
+ $deployment = $this->loadDeploymentForStage($stageName);
+
+ $this->add(Html::tag('p')->add(Html::sprintf(
+ 'The configuration for this object has been rendered by Icinga'
+ . ' Director %s to %s',
+ DateFormatter::timeAgo(strtotime($deployment->start_time, false)),
+ $this->linkToSourceLocation($deployment, $source)
+ )));
+ }
+ }
+
+ protected function loadDeploymentForStage($stageName)
+ {
+ $db = $this->db->getDbAdapter();
+ $query = $db->select()->from(
+ ['dl' => 'director_deployment_log'],
+ ['id', 'start_time', 'config_checksum']
+ )->where('stage_name = ?', $stageName)->order('id DESC')->limit(1);
+
+ return $db->fetchRow($query);
+ }
+
+ /**
+ * @param $deployment
+ * @param $source
+ * @return Link
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkToSourceLocation($deployment, $source)
+ {
+ $filename = $source->director_relative;
+
+ return Link::create(
+ sprintf('%s:%s', $filename, $source->first_line),
+ 'director/config/file',
+ [
+ 'config_checksum' => $this->getChecksum($deployment->config_checksum),
+ 'deployment_id' => $deployment->id,
+ 'backTo' => 'deployment',
+ 'file_path' => $filename,
+ 'highlight' => $source->first_line,
+ 'highlightSeverity' => 'ok'
+ ]
+ );
+ }
+
+ protected function formatConsole($output)
+ {
+ return Html::tag('pre', ['class' => 'logfile'], $output);
+ }
+}
diff --git a/library/Director/Web/Widget/ImportSourceDetails.php b/library/Director/Web/Widget/ImportSourceDetails.php
new file mode 100644
index 0000000..32eef7f
--- /dev/null
+++ b/library/Director/Web/Widget/ImportSourceDetails.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Web\Widget\Hint;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Forms\ImportCheckForm;
+use Icinga\Module\Director\Forms\ImportRunForm;
+use Icinga\Module\Director\Objects\ImportSource;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class ImportSourceDetails extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $source;
+
+ public function __construct(ImportSource $source)
+ {
+ $this->source = $source;
+ }
+
+ protected function assemble()
+ {
+ $source = $this->source;
+ $description = $source->get('description');
+ if ($description !== null && strlen($description)) {
+ $this->add(Html::tag('p', null, $description));
+ }
+
+ switch ($source->get('import_state')) {
+ case 'unknown':
+ $this->add(Hint::warning($this->translate(
+ "It's currently unknown whether we are in sync with this Import Source."
+ . ' You should either check for changes or trigger a new Import Run.'
+ )));
+ break;
+ case 'in-sync':
+ $this->add(Hint::ok(sprintf(
+ $this->translate(
+ 'This Import Source was last found to be in sync at %s.'
+ ),
+ $source->last_attempt
+ )));
+ // TODO: check whether...
+ // - there have been imports since then, differing from former ones
+ // - there have been activities since then
+ break;
+ case 'pending-changes':
+ $this->add(Hint::warning($this->translate(
+ 'There are pending changes for this Import Source. You should trigger a new'
+ . ' Import Run.'
+ )));
+ break;
+ case 'failing':
+ $this->add(Hint::error(sprintf(
+ $this->translate(
+ 'This Import Source failed when last checked at %s: %s'
+ ),
+ $source->last_attempt,
+ $source->last_error_message
+ )));
+ break;
+ default:
+ $this->add(Hint::error(sprintf(
+ $this->translate('This Import Source has an invalid state: %s'),
+ $source->get('import_state')
+ )));
+ }
+
+ $this->add(
+ ImportCheckForm::load()
+ ->setImportSource($source)
+ ->handleRequest()
+ );
+ $this->add(
+ ImportRunForm::load()
+ ->setImportSource($source)
+ ->handleRequest()
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/InspectPackages.php b/library/Director/Web/Widget/InspectPackages.php
new file mode 100644
index 0000000..f9b8864
--- /dev/null
+++ b/library/Director/Web/Widget/InspectPackages.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use ipl\Html\Html;
+use ipl\Html\Table;
+
+class InspectPackages
+{
+ use TranslationHelper;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var string */
+ protected $baseUrl;
+
+ public function __construct(Db $db, $baseUrl)
+ {
+ $this->db = $db;
+ $this->baseUrl = $baseUrl;
+ }
+
+ public function getContent(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null)
+ {
+ if ($endpoint === null) {
+ return $this->getRootEndpoints();
+ } elseif ($package === null) {
+ return $this->getPackages($endpoint);
+ } elseif ($stage === null) {
+ return $this->getStages($endpoint, $package);
+ } elseif ($file === null) {
+ return $this->getFiles($endpoint, $package, $stage);
+ } else {
+ return $this->getFile($endpoint, $package, $stage, $file);
+ }
+ }
+
+ public function getTitle(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null)
+ {
+ if ($endpoint === null) {
+ return $this->translate('Endpoint in your Root Zone');
+ } elseif ($package === null) {
+ return \sprintf($this->translate('Packages on Endpoint: %s'), $endpoint->getObjectName());
+ } elseif ($stage === null) {
+ return \sprintf($this->translate('Stages in Package: %s'), $package);
+ } elseif ($file === null) {
+ return \sprintf($this->translate('Files in Stage: %s'), $stage);
+ } else {
+ return \sprintf($this->translate('File Content: %s'), $file);
+ }
+ }
+
+ public function getBreadCrumb(IcingaEndpoint $endpoint = null, $package = null, $stage = null)
+ {
+ $parts = [
+ 'endpoint' => $endpoint === null ? null : $endpoint->getObjectName(),
+ 'package' => $package,
+ 'stage' => $stage,
+ ];
+
+ $params = [];
+ // No root zone link for now:
+ // $result = [Link::create($this->translate('Root Zone'), $this->baseUrl)];
+ $result = [Html::tag('a', ['href' => '#'], $this->translate('Root Zone'))];
+ foreach ($parts as $name => $value) {
+ if ($value === null) {
+ break;
+ }
+ $params[$name] = $value;
+ $result[] = Link::create($value, $this->baseUrl, $params);
+ }
+
+ return Html::tag('ul', ['class' => 'breadcrumb'], Html::wrapEach($result, 'li'));
+ }
+
+ protected function getRootEndpoints()
+ {
+ $table = $this->prepareTable();
+ foreach ($this->db->getEndpointNamesInDeploymentZone() as $name) {
+ $table->add(Table::row([
+ Link::create($name, $this->baseUrl, [
+ 'endpoint' => $name,
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getPackages(IcingaEndpoint $endpoint)
+ {
+ $table = $this->prepareTable();
+ $api = $endpoint->api();
+ foreach ($api->getPackages() as $package) {
+ $table->add(Table::row([
+ Link::create($package->name, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package->name,
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getStages(IcingaEndpoint $endpoint, $packageName)
+ {
+ $table = $this->prepareTable();
+ $api = $endpoint->api();
+ foreach ($api->getPackages() as $package) {
+ if ($package->name !== $packageName) {
+ continue;
+ }
+ foreach ($package->stages as $stage) {
+ $label = [$stage];
+ if ($stage === $package->{'active-stage'}) {
+ $label[] = Html::tag('small', [' (', $this->translate('active'), ')']);
+ }
+
+ $table->add(Table::row([
+ Link::create($label, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package->name,
+ 'stage' => $stage
+ ])
+ ]));
+ }
+ }
+
+ return $table;
+ }
+
+ protected function getFiles(IcingaEndpoint $endpoint, $package, $stage)
+ {
+ $table = $this->prepareTable();
+ $table->getAttributes()->set('data-base-target', '_next');
+ foreach ($endpoint->api()->listStageFiles($stage, $package) as $filename) {
+ $table->add($table->row([
+ Link::create($filename, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package,
+ 'stage' => $stage,
+ 'file' => $filename
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getFile(IcingaEndpoint $endpoint, $package, $stage, $file)
+ {
+ return Html::tag('pre', $endpoint->api()->getStagedFile($stage, $file, $package));
+ }
+
+ protected function prepareTable($headerCols = [])
+ {
+ $table = new Table();
+ $table->addAttributes([
+ 'class' => ['common-table', 'table-row-selectable'],
+ 'data-base-target' => '_self'
+ ]);
+ if (! empty($headerCols)) {
+ $table->add($table::row($headerCols, null, 'th'));
+ }
+
+ return $table;
+ }
+}
diff --git a/library/Director/Web/Widget/JobDetails.php b/library/Director/Web/Widget/JobDetails.php
new file mode 100644
index 0000000..3a530a2
--- /dev/null
+++ b/library/Director/Web/Widget/JobDetails.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Date\DateFormatter;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Objects\DirectorJob;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class JobDetails extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /**
+ * JobDetails constructor.
+ * @param DirectorJob $job
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function __construct(DirectorJob $job)
+ {
+ $runInterval = $job->get('run_interval');
+ if ($job->hasBeenDisabled()) {
+ $this->add(Hint::error(sprintf(
+ $this->translate(
+ 'This job would run every %ds. It has been disabled and will'
+ . ' therefore not be executed as scheduled'
+ ),
+ $runInterval
+ )));
+ } else {
+ //$class = $job->job(); echo $class::getDescription()
+ $msg = $job->isPending()
+ ? sprintf(
+ $this->translate('This job runs every %ds and is currently pending'),
+ $runInterval
+ )
+ : sprintf(
+ $this->translate('This job runs every %ds.'),
+ $runInterval
+ );
+ $this->add(Html::tag('p', null, $msg));
+ }
+
+ $tsLastAttempt = $job->get('ts_last_attempt');
+ if ($tsLastAttempt) {
+ $ts = \strtotime($tsLastAttempt);
+ $timeAgo = Html::tag('span', [
+ 'class' => 'time-ago',
+ 'title' => DateFormatter::formatDateTime($ts)
+ ], DateFormatter::timeAgo($ts));
+ if ($job->get('last_attempt_succeeded') === 'y') {
+ $this->add(Hint::ok(Html::sprintf(
+ $this->translate('The last attempt succeeded %s'),
+ $timeAgo
+ )));
+ } else {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate('The last attempt failed %s: %s'),
+ $timeAgo,
+ $job->get('last_error_message')
+ )));
+ }
+ } else {
+ $this->add(Hint::warning($this->translate('This job has not been executed yet')));
+ }
+ }
+}
diff --git a/library/Director/Web/Widget/ListItem.php b/library/Director/Web/Widget/ListItem.php
new file mode 100644
index 0000000..ec326cc
--- /dev/null
+++ b/library/Director/Web/Widget/ListItem.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+
+class ListItem extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ /**
+ * @param ValidHtml|array|string $content
+ * @param Attributes|array $attributes
+ *
+ * @return $this
+ */
+ public function addItem($content, $attributes = null)
+ {
+ return $this->add(
+ Html::tag('li', $attributes, $content)
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/NotInBranchedHint.php b/library/Director/Web/Widget/NotInBranchedHint.php
new file mode 100644
index 0000000..222934b
--- /dev/null
+++ b/library/Director/Web/Widget/NotInBranchedHint.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\Html;
+
+class NotInBranchedHint extends Hint
+{
+ use TranslationHelper;
+
+ public function __construct($forbiddenAction, Branch $branch, Auth $auth)
+ {
+ parent::__construct(Html::sprintf(
+ $this->translate('%s is not available while being in a Configuration Branch: %s'),
+ $forbiddenAction,
+ Branch::requireHook()->linkToBranch($branch, $auth, $branch->getName())
+ ), 'info');
+ }
+}
diff --git a/library/Director/Web/Widget/OrderedList.php b/library/Director/Web/Widget/OrderedList.php
new file mode 100644
index 0000000..8f888de
--- /dev/null
+++ b/library/Director/Web/Widget/OrderedList.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+class OrderedList extends AbstractList
+{
+ protected $tag = 'ol';
+}
diff --git a/library/Director/Web/Widget/ShowConfigFile.php b/library/Director/Web/Widget/ShowConfigFile.php
new file mode 100644
index 0000000..77d32cf
--- /dev/null
+++ b/library/Director/Web/Widget/ShowConfigFile.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigFile;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+
+class ShowConfigFile extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $file;
+
+ protected $highlight;
+
+ protected $highlightSeverity;
+
+ public function __construct(
+ IcingaConfigFile $file,
+ $highlight = null,
+ $highlightSeverity = null
+ ) {
+ $this->file = $file;
+ $this->highlight = $highlight;
+ $this->highlightSeverity = $highlightSeverity;
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function assemble()
+ {
+ $source = $this->linkObjects(Html::escape($this->file->getContent()));
+ if ($this->highlight) {
+ $source = $this->highlight(
+ $source,
+ $this->highlight,
+ $this->highlightSeverity
+ );
+ }
+
+ $this->add(Html::tag(
+ 'pre',
+ ['class' => 'generated-config'],
+ new HtmlString($source)
+ ));
+ }
+
+ /**
+ * @param $match
+ * @return string
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkObject($match)
+ {
+ if ($match[2] === 'Service') {
+ return $match[0];
+ }
+ $controller = $match[2];
+
+ if ($match[2] === 'CheckCommand') {
+ $controller = 'command';
+ }
+
+ $name = $this->decode($match[3]);
+ return sprintf(
+ '%s %s &quot;%s&quot; {',
+ $match[1],
+ $match[2],
+ Link::create(
+ $name,
+ 'director/' . $controller,
+ ['name' => $name],
+ ['data-base-target' => '_next']
+ )
+ );
+ }
+
+ protected function decode($str)
+ {
+ return htmlspecialchars_decode($str, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5);
+ }
+
+ protected function linkObjects($config)
+ {
+ $pattern = '/^(object|template)\s([A-Z][A-Za-z]*?)\s&quot;(.+?)&quot;\s{/m';
+
+ return preg_replace_callback(
+ $pattern,
+ [$this, 'linkObject'],
+ $config
+ );
+ }
+
+ protected function highlight($what, $line, $severity)
+ {
+ $lines = explode("\n", $what);
+ $lines[$line - 1] = '<span class="highlight ' . $severity . '">' . $lines[$line - 1] . '</span>';
+ return implode("\n", $lines);
+ }
+}
diff --git a/library/Director/Web/Widget/SyncRunDetails.php b/library/Director/Web/Widget/SyncRunDetails.php
new file mode 100644
index 0000000..408e8f6
--- /dev/null
+++ b/library/Director/Web/Widget/SyncRunDetails.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\SyncRun;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use function sprintf;
+
+class SyncRunDetails extends NameValueTable
+{
+ use TranslationHelper;
+
+ const URL_ACTIVITIES = 'director/config/activities';
+
+ /** @var SyncRun */
+ protected $run;
+
+ public function __construct(SyncRun $run)
+ {
+ $this->run = $run;
+ $this->getAttributes()->add('data-base-target', '_next'); // eigentlich nur runSummary
+ $this->addNameValuePairs([
+ $this->translate('Start time') => $run->get('start_time'),
+ $this->translate('Duration') => sprintf('%.2fs', $run->get('duration_ms') / 1000),
+ $this->translate('Activity') => $this->runSummary($run)
+ ]);
+ }
+
+ /**
+ * @param SyncRun $run
+ * @return array
+ */
+ protected function runSummary(SyncRun $run)
+ {
+ $html = [];
+ $total = $run->countActivities();
+ if ($total === 0) {
+ $html[] = $this->translate('No changes have been made');
+ } else {
+ if ($total === 1) {
+ $html[] = $this->translate('One object has been modified');
+ } else {
+ $html[] = sprintf(
+ $this->translate('%s objects have been modified'),
+ $total
+ );
+ }
+
+ /** @var Db $db */
+ $db = $run->getConnection();
+ $formerId = $db->fetchActivityLogIdByChecksum($run->get('last_former_activity'));
+ if ($formerId === null) {
+ return $html;
+ }
+ $lastId = $db->fetchActivityLogIdByChecksum($run->get('last_related_activity'));
+
+ if ($formerId !== $lastId) {
+ $idRangeEx = sprintf(
+ 'id>%d&id<=%d',
+ $formerId,
+ $lastId
+ );
+ } else {
+ $idRangeEx = null;
+ }
+
+ $links = new HtmlDocument();
+ $links->setSeparator(', ');
+ $links->add([
+ $this->activitiesLink(
+ 'objects_created',
+ $this->translate('%d created'),
+ DirectorActivityLog::ACTION_CREATE,
+ $idRangeEx
+ ),
+ $this->activitiesLink(
+ 'objects_modified',
+ $this->translate('%d modified'),
+ DirectorActivityLog::ACTION_MODIFY,
+ $idRangeEx
+ ),
+ $this->activitiesLink(
+ 'objects_deleted',
+ $this->translate('%d deleted'),
+ DirectorActivityLog::ACTION_DELETE,
+ $idRangeEx
+ ),
+ ]);
+
+ if ($idRangeEx && count($links) > 1) {
+ $links->add(new Link(
+ $this->translate('Show all actions'),
+ self::URL_ACTIVITIES,
+ ['idRangeEx' => $idRangeEx]
+ ));
+ }
+
+ if (! $links->isEmpty()) {
+ $html[] = ': ';
+ $html[] = $links;
+ }
+ }
+
+ return $html;
+ }
+
+ protected function activitiesLink($key, $label, $action, $rangeFilter)
+ {
+ $count = $this->run->get($key);
+ if ($count > 0) {
+ if ($rangeFilter) {
+ return new Link(
+ sprintf($label, $count),
+ self::URL_ACTIVITIES,
+ ['action' => $action, 'idRangeEx' => $rangeFilter]
+ );
+ }
+
+ return sprintf($label, $count);
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Web/Widget/UnorderedList.php b/library/Director/Web/Widget/UnorderedList.php
new file mode 100644
index 0000000..f01dbe3
--- /dev/null
+++ b/library/Director/Web/Widget/UnorderedList.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+class UnorderedList extends AbstractList
+{
+ protected $tag = 'ul';
+}
diff --git a/library/Director/Web/Window.php b/library/Director/Web/Window.php
new file mode 100644
index 0000000..3415dd3
--- /dev/null
+++ b/library/Director/Web/Window.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Icinga\Module\Director\Web;
+
+use Icinga\Web\Window as WebWindow;
+
+class Window extends WebWindow
+{
+ public function __construct($id)
+ {
+ parent::__construct(\preg_replace('/_.+$/', '', $id));
+ }
+}