diff options
Diffstat (limited to 'library/Director/Web')
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+\"\;)(.+)(\"\;)/m', + [$this, 'linkImport'], + $plain + ); + + if ($type !== 'command') { + $plain = preg_replace_callback( + '/^(\s+(?:check_|event_)?command\s+=\s+\"\;)(.+)(\"\;)/m', + [$this, 'linkCommand'], + $plain + ); + } + + $plain = preg_replace_callback( + '/^(\s+host_name\s+=\s+\"\;)(.+)(\"\;)/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('"', '"', $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('&', '&', $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 "%s" {', + $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"(.+?)"\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)); + } +} |