From c4f3792d2fd33bf894c005d686b994b3e9a8a394 Mon Sep 17 00:00:00 2001 From: raviks789 Date: Tue, 11 Mar 2025 09:27:04 +0100 Subject: [PATCH] Alternative custom property support WIP: ipl form for custom properties DirectorObjectForm: Do not show custom variables for hosts Show inherited values in placeholder Fixes the instantiable dictionary loading in host objects, and adds a duplicate save button to fix the storing of custom properties. Renaming of variables for better readability. --- application/controllers/HostController.php | 182 +++++-- .../controllers/PropertiesController.php | 67 +++ .../controllers/PropertyController.php | 241 +++++++++ application/forms/CustomPropertiesForm.php | 490 ++++++++++++++++++ application/forms/ObjectPropertyForm.php | 172 ++++++ application/forms/PropertyForm.php | 276 ++++++++++ configuration.php | 6 + .../Web/Controller/ObjectController.php | 79 ++- .../Director/Web/Form/DirectorObjectForm.php | 17 +- .../Web/Form/Element/ArrayElement.php | 71 +++ .../Director/Web/Form/Element/IplBoolean.php | 64 +++ library/Director/Web/Tabs/ObjectTabs.php | 10 +- .../Web/Widget/ObjectPropertyTable.php | 57 ++ library/Director/Web/Widget/PropertyTable.php | 50 ++ public/css/custom-properties-form.less | 29 ++ public/css/module.less | 4 + public/css/property-table.less | 3 + public/js/module.js | 3 +- schema/mysql.sql | 29 +- 19 files changed, 1808 insertions(+), 42 deletions(-) create mode 100644 application/controllers/PropertiesController.php create mode 100644 application/controllers/PropertyController.php create mode 100644 application/forms/CustomPropertiesForm.php create mode 100644 application/forms/ObjectPropertyForm.php create mode 100644 application/forms/PropertyForm.php create mode 100644 library/Director/Web/Form/Element/ArrayElement.php create mode 100644 library/Director/Web/Form/Element/IplBoolean.php create mode 100644 library/Director/Web/Widget/ObjectPropertyTable.php create mode 100644 library/Director/Web/Widget/PropertyTable.php create mode 100644 public/css/custom-properties-form.less create mode 100644 public/css/property-table.less diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php index 5edfbcfa..09be4ce1 100644 --- a/application/controllers/HostController.php +++ b/application/controllers/HostController.php @@ -4,6 +4,9 @@ namespace Icinga\Module\Director\Controllers; use gipfl\Web\Widget\Hint; use Icinga\Module\Director\Auth\Permission; +use Icinga\Module\Director\CustomVariable\CustomVariables; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Forms\CustomPropertiesForm; use Icinga\Module\Director\Integration\Icingadb\IcingadbBackend; use Icinga\Module\Director\Integration\MonitoringModule\Monitoring; use Icinga\Module\Director\Web\Table\ObjectsTableService; @@ -12,7 +15,6 @@ use gipfl\IcingaWeb2\Link; use gipfl\IcingaWeb2\Url; use gipfl\IcingaWeb2\Widget\Tabs; use Exception; -use Icinga\Module\Director\CustomVariable\CustomVariableDictionary; use Icinga\Module\Director\Db\AppliedServiceSetLoader; use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; use Icinga\Module\Director\Forms\IcingaAddServiceForm; @@ -25,9 +27,10 @@ use Icinga\Module\Director\Restriction\HostgroupRestriction; use Icinga\Module\Director\Repository\IcingaTemplateRepository; use Icinga\Module\Director\Web\Controller\ObjectController; use Icinga\Module\Director\Web\SelfService; -use Icinga\Module\Director\Web\Table\IcingaHostAppliedForServiceTable; use Icinga\Module\Director\Web\Table\IcingaHostAppliedServicesTable; use Icinga\Module\Director\Web\Table\IcingaServiceSetServiceTable; +use ipl\Web\Widget\ButtonLink; +use PDO; class HostController extends ObjectController { @@ -89,6 +92,115 @@ class HostController extends ObjectController $this->addOptionalMonitoringLink(); } + public function variablesAction() + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + $type = $this->getType(); + + $this->addTitle( + $this->translate('Custom Variables: %s'), + $object->getObjectName() + ); + + $objectProperties = $this->getObjectCustomProperties(); + if ($this->object->isTemplate()) { + $this->actions()->add( + (new ButtonLink( + $this->translate('Add Property'), + Url::fromPath('director/host/add-property', ['uuid' => $this->getUuidFromUrl()])->getAbsoluteUrl(), + null, + ['class' => 'control-button'] + ))->openInModal() + ); + + if ($objectProperties) { + $this->actions()->add( + (new ButtonLink( + $this->translate('Remove Property'), + Url::fromPath( + 'director/host/remove-property', + ['uuid' => $this->getUuidFromUrl()] + )->getAbsoluteUrl(), + null, + ['class' => 'control-button'] + ))->openInModal() + ); + } + } + + $vars = json_decode(json_encode($this->object->getVars()), true); + + if ($objectProperties) { + $form = (new CustomPropertiesForm($this->db(), $object, $objectProperties)) + ->populate($vars) + ->on(CustomPropertiesForm::ON_SUCCESS, function () { + $this->redirectNow('__REFRESH__'); + }) + ->handleRequest($this->getServerRequest()); + + try { + $this->content()->add($form); + } catch (NestingError $e) { + $this->content()->add(Hint::error($e->getMessage())); + } + } + + $this->tabs()->activate('variables'); + } + + /** + * Get custom properties for the host. + * + * @return array + */ + protected function getObjectCustomProperties(): array + { + if ($this->object->uuid === null) { + return []; + } + + $type = $this->object->getShortTableName(); + + $parents = $this->object->listAncestorIds(); + + $uuids = []; + $db = $this->db(); + foreach ($parents as $parent) { + $uuids[] = IcingaHost::load($parent, $db)->get('uuid'); + } + + $uuids[] = $this->object->get('uuid'); + $query = $db->getDbAdapter() + ->select() + ->from( + ['dp' => 'director_property'], + [ + 'key_name' => 'dp.key_name', + 'uuid' => 'dp.uuid', + 'value_type' => 'dp.value_type', + 'label' => 'dp.label', + 'instantiable' => 'dp.instantiable', + 'required' => 'iop.required', + 'children' => 'COUNT(cdp.uuid)' + ] + ) + ->join(['iop' => "icinga_$type" . '_property'], 'dp.uuid = iop.property_uuid', []) + ->joinLeft(['cdp' => 'director_property'], 'cdp.parent_uuid = dp.uuid', []) + ->where('iop.' . $type . '_uuid IN (?)', $uuids) + ->group(['dp.uuid', 'dp.key_name', 'dp.value_type', 'dp.label', 'dp.instantiable', 'iop.required']) + ->order('children') + ->order('instantiable') + ->order('key_name'); + + $result = []; + foreach ($db->getDbAdapter()->fetchAll($query, fetchMode: PDO::FETCH_ASSOC) as $row) { + $result[$row['key_name']] = $row; + } + + return $result; + } + public function serviceAction() { $host = $this->getHostObject(); @@ -99,7 +211,7 @@ class HostController extends ObjectController ->setBranch($this->getBranch()) ->setHost($host) ->setDb($this->db()) - ->handleRequest() + ->handleRequest(), ); } @@ -114,7 +226,7 @@ class HostController extends ObjectController ->setBranch($this->getBranch()) ->setHost($host) ->setDb($this->db()) - ->handleRequest() + ->handleRequest(), ); } @@ -128,12 +240,12 @@ class HostController extends ObjectController $this->translate('Add service'), 'director/host/service', ['name' => $hostname], - ['class' => 'icon-plus'] + ['class' => 'icon-plus'], ))->add(Link::create( $this->translate('Add service set'), 'director/host/serviceset', ['name' => $hostname], - ['class' => 'icon-plus'] + ['class' => 'icon-plus'], )); } @@ -159,7 +271,7 @@ class HostController extends ObjectController } elseif ($auth->hasPermission($this->getServicesReadOnlyPermission())) { $redirectUrl = Url::fromPath('director/host/servicesro', [ 'name' => $hostName, - 'service' => $serviceName + 'service' => $serviceName, ]); } else { $redirectUrl = Url::fromPath('director/host/invalidservice', [ @@ -179,7 +291,7 @@ class HostController extends ObjectController if (! $this->showInfoForNonDirectorService()) { $this->content()->add(Hint::error(sprintf( $this->translate('No such service: %s'), - $this->params->get('service') + $this->params->get('service'), ))); } @@ -199,7 +311,7 @@ class HostController extends ObjectController '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) + Html::tag('strong', null, $source->path), ))); } } @@ -245,8 +357,8 @@ class HostController extends ObjectController $content->add( $table->setTitle(sprintf( $this->translate('Inherited from %s'), - $parent->getObjectName() - )) + $parent->getObjectName(), + )), ); } } @@ -268,7 +380,7 @@ class HostController extends ObjectController ->setBranch($branch) ->setAffectedHost($host) ->setTitle($title) - ->removeQueryLimit() + ->removeQueryLimit(), ); } @@ -326,8 +438,8 @@ class HostController extends ObjectController $content->add( $table->setTitle(sprintf( 'Inherited from %s', - $parent->getObjectName() - )) + $parent->getObjectName(), + )), ); } } @@ -348,7 +460,7 @@ class HostController extends ObjectController ->setAffectedHost($host) ->setReadonly() ->highlightService($service) - ->setTitle($title) + ->setTitle($title), ); } @@ -379,15 +491,15 @@ class HostController extends ObjectController $query = $db->getDbAdapter()->select() ->from( array('ss' => 'icinga_service_set'), - 'ss.*' + 'ss.*', )->join( array('hsi' => 'icinga_service_set_inheritance'), 'hsi.parent_service_set_id = ss.id', - array() + array(), )->join( array('hs' => 'icinga_service_set'), 'hs.id = hsi.service_set_id', - array() + array(), )->where('hs.host_id = ?', $host->get('id')); $sets = IcingaServiceSet::loadAll($db, $query, 'object_name'); @@ -428,7 +540,7 @@ class HostController extends ObjectController $this->addTitle( $this->translate('Applied service: %s'), - $serviceName + $serviceName, ); $this->content()->add( @@ -438,7 +550,7 @@ class HostController extends ObjectController ->setHost($host) ->setApplyGenerated($parent) ->setObject($service) - ->handleRequest() + ->handleRequest(), ); $this->commonForServices(); @@ -456,7 +568,7 @@ class HostController extends ObjectController $parent = IcingaService::load([ 'object_name' => $serviceName, - 'host_id' => $from->get('id') + 'host_id' => $from->get('id'), ], $this->db()); // TODO: we want to eventually show the host template name, doesn't work @@ -493,21 +605,21 @@ class HostController extends ObjectController $db = $this->db()->getDbAdapter(); $query = $db->select()->from( array('ss' => 'icinga_service_set'), - array('id' => 'ss.id') + array('id' => 'ss.id'), )->join( array('si' => 'icinga_service_set_inheritance'), 'si.service_set_id = ss.id', - array() + array(), )->where( 'si.parent_service_set_id = ?', - $this->params->get('setId') + $this->params->get('setId'), )->where('ss.host_id = ?', $this->object->get('id')); IcingaServiceSet::loadWithAutoIncId($db->fetchOne($query), $this->db())->delete(); $this->redirectNow( Url::fromPath('director/host/services', array( - 'name' => $this->object->getObjectName() - )) + 'name' => $this->object->getObjectName(), + )), ); } @@ -521,7 +633,7 @@ class HostController extends ObjectController $serviceName = $this->params->get('service'); $setParams = [ 'object_name' => $this->params->get('set'), - 'host_id' => $host->get('id') + 'host_id' => $host->get('id'), ]; $setTemplate = IcingaServiceSet::load($this->params->get('set'), $db); if (IcingaServiceSet::exists($setParams, $db)) { @@ -532,7 +644,7 @@ class HostController extends ObjectController $service = IcingaService::load([ 'object_name' => $serviceName, - 'service_set_id' => $setTemplate->get('id') + 'service_set_id' => $setTemplate->get('id'), ], $this->db()); $service = IcingaService::create([ 'id' => $service->get('id'), @@ -548,7 +660,7 @@ class HostController extends ObjectController $this->translate('%s on %s (from set: %s)'), $serviceName, $host->getObjectName(), - $set->getObjectName() + $set->getObjectName(), ); $form = IcingaServiceForm::load() @@ -570,7 +682,7 @@ class HostController extends ObjectController $this->translate('back'), 'director/host/services', ['name' => $host->getObjectName()], - ['class' => 'icon-left-big'] + ['class' => 'icon-left-big'], )); $this->tabs()->activate('services'); } @@ -605,8 +717,8 @@ class HostController extends ObjectController $this->translate('Show'), $backend->getHostUrl($host->getObjectName()), null, - ['class' => 'icon-globe critical', 'data-base-target' => '_next'] - ) + ['class' => 'icon-globe critical', 'data-base-target' => '_next'], + ), ); // Intentionally placed here, show it only for deployed Hosts @@ -629,12 +741,12 @@ class HostController extends ObjectController [ 'type' => 'host', 'plural' => 'hosts', - 'name' => $this->object->getObjectName() + 'name' => $this->object->getObjectName(), ], [ 'class' => 'icon-zoom-in', - 'data-base-target' => '_next' - ] + 'data-base-target' => '_next', + ], )); } diff --git a/application/controllers/PropertiesController.php b/application/controllers/PropertiesController.php new file mode 100644 index 00000000..e6b03e41 --- /dev/null +++ b/application/controllers/PropertiesController.php @@ -0,0 +1,67 @@ +addTitleTab($this->translate('Properties')); + + $db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + )->getDbAdapter(); + + $query = $db->select() + ->from('director_property') + ->where('parent_uuid IS NULL') + ->order('key_name'); + + $properties = new PropertyTable($db->fetchAll($query)); + + $this->addControl(Html::tag('div', ['class' => 'property-form'], [ + (new ButtonLink( + [Text::create('Add property')], + Url::fromPath('director/properties/add'), + null, + [ + 'class' => 'control-button' + ] + ))->setBaseTarget('_next') + ])); + + $this->addContent($properties); + } + + public function addAction() + { + $this->addTitleTab($this->translate('Add property')); + $db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + ); + + $propertyForm = (new PropertyForm($db)) + ->on(PropertyForm::ON_SUCCESS, function (PropertyForm $form) { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been added'), + $form->getValue('key_name') + )); + + $this->redirectNow(Url::fromPath('director/property', ['uuid' => $form->getUUid()->toString()])); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + } +} diff --git a/application/controllers/PropertyController.php b/application/controllers/PropertyController.php new file mode 100644 index 00000000..822d7938 --- /dev/null +++ b/application/controllers/PropertyController.php @@ -0,0 +1,241 @@ +db = Db::fromResourceName( + Config::module('director')->get('db', 'resource') + ); + } + + public function indexAction() + { + $uuid = $this->params->shiftRequired('uuid'); + $uuid = Uuid::fromString($uuid); + $db = $this->db->getDbAdapter(); + $property = $this->fetchProperty($uuid); + + if ($property['value_type'] === 'array' && $property['instantiable'] === 'y') { + $itemTypeQuery = $db + ->select()->from('director_property', 'value_type') + ->where( + 'parent_uuid = ? AND key_name = \'0\'', + $uuid->getBytes() + ); + + $property['item_type'] = $db->fetchOne($itemTypeQuery); + } + + $showFields = ($property['value_type'] === 'array' && $property['instantiable'] !== 'y') + || $property['value_type'] === 'dict'; + $propertyForm = (new PropertyForm($this->db, $uuid)) + ->populate($property) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(PropertyForm::ON_SENT, function (PropertyForm $form) use (&$showFields) { + $values = $form->getValues(); + $showFields = ($values['value_type'] === 'array' && $values['instantiable'] !== 'y') + || $values['value_type'] === 'dict'; + }) + ->on(PropertyForm::ON_SUCCESS, function (PropertyForm $form) { + if ($form->getPressedSubmitElement()->getName() === 'delete') { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been deleted'), + $form->getValue('key_name') + )); + + $this->redirectNow('__CLOSE__'); + } else { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + + $this->redirectNow( + Url::fromPath('director/property', ['uuid' => $form->getUUid()->toString()]) + ); + } + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + + if ($showFields) { + $this->addContent(new HtmlElement('h2', null, Text::create($this->translate('Fields')))); + $button = (new ButtonLink( + Text::create($this->translate('Add Field')), + Url::fromPath('director/property/add-field', [ + 'uuid' => $uuid->toString() + ]), + null, + ['class' => 'control-button'] + ))->openInModal(); + + $fieldQuery = $db + ->select() + ->from('director_property') + ->where('parent_uuid = ?', $uuid->getBytes()) + ->order('key_name'); + + $this->addContent($button); + + $fields = new PropertyTable($db->fetchAll($fieldQuery), true); + $this->addContent($fields); + } + + $this->addTitleTab($this->translate('Property') . ': ' . $property['key_name']); + } + + public function addFieldAction() + { + $uuid = $this->params->shiftRequired('uuid'); + $this->addTitleTab($this->translate('Add Field')); + $uuid = Uuid::fromString($uuid); + + $parent = $this->fetchProperty($uuid); + $hideKeyNameField = $parent['value_type'] === 'array' + && $parent['instantiable'] === 'n'; + + $propertyForm = (new PropertyForm($this->db, null, true, $uuid)) + ->setHideKeyNameElement($hideKeyNameField) + ->setIsNestedField($parent['parent_uuid'] !== null) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(PropertyForm::ON_SUCCESS, function (PropertyForm $form) { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->sendExtraUpdates(['#col1']); + $this->redirectNow( + Url::fromPath('director/property', ['uuid' => $form->getParentUUid()->toString()]) + ); + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + } + + public function editFieldAction() + { + $uuid = Uuid::fromString($this->params->shiftRequired('uuid')); + $parentUuid = Uuid::fromString($this->params->shiftRequired('parent_uuid')); + + $parent = $this->fetchProperty($parentUuid); + $hideKeyNameField = $parent['value_type'] === 'array' + && $parent['instantiable'] === 'n'; + + $property = $this->fetchProperty($uuid); + $db = $this->db->getDbAdapter(); + + if ($property['value_type'] === 'array' && $property['instantiable'] === 'y') { + $itemTypeQuery = $db + ->select()->from('director_property', 'value_type') + ->where( + 'parent_uuid = ? AND key_name = \'0\'', + $uuid->getBytes() + ); + + $property['item_type'] = $db->fetchOne($itemTypeQuery); + } + + $this->addTitleTab(sprintf($this->translate('Edit Field: %s'), $property['key_name'])); + + $showFields = ($property['value_type'] === 'array' && $property['instantiable'] !== 'y') + || $property['value_type'] === 'dict'; + $propertyForm = (new PropertyForm($this->db, $uuid, true, $parentUuid)) + ->setHideKeyNameElement($hideKeyNameField) + ->setIsNestedField($parent['parent_uuid'] !== null) + ->populate($property) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(PropertyForm::ON_SENT, function (PropertyForm $form) use (&$showFields) { + $values = $form->getValues(); + $showFields = ($values['value_type'] === 'array' && $values['instantiable'] !== 'y') + || $values['value_type'] === 'dict'; + }) + ->on(PropertyForm::ON_SUCCESS, function (PropertyForm $form) { + if ($form->getPressedSubmitElement()->getName() === 'delete') { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been deleted'), + $form->getValue('key_name') + )); + + $this->redirectNow('__CLOSE__'); + } else { + Notification::success(sprintf( + $this->translate('Property "%s" has successfully been saved'), + $form->getValue('key_name') + )); + + $this->redirectNow(Url::fromRequest()->getAbsoluteUrl()); + } + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($propertyForm); + + if ($showFields) { + $this->addContent(new HtmlElement('h2', null, Text::create($this->translate('Fields')))); + $button = (new ButtonLink( + Text::create($this->translate('Add Field')), + Url::fromPath('director/property/add-field', [ + 'uuid' => $uuid->toString() + ]), + null, + ['class' => 'control-button'] + ))->openInModal(); + + $fieldQuery = $db + ->select() + ->from('director_property') + ->where('parent_uuid = ?', $uuid->getBytes()) + ->order('key_name'); + + $this->addContent($button); + + $fields = new PropertyTable($db->fetchAll($fieldQuery), true); + $this->addContent($fields); + } + } + + /** + * Fetch property for the given UUID + * + * @param UuidInterface $uuid UUID of the given property + * + * @return array + */ + private function fetchProperty(UuidInterface $uuid): array + { + $db = $this->db->getDbAdapter(); + + $query = $db + ->select()->from('director_property') + ->where('uuid = ?', $uuid->getBytes()); + + return $db->fetchRow($query, [], Zend_Db::FETCH_ASSOC); + } +} diff --git a/application/forms/CustomPropertiesForm.php b/application/forms/CustomPropertiesForm.php new file mode 100644 index 00000000..1660c5ac --- /dev/null +++ b/application/forms/CustomPropertiesForm.php @@ -0,0 +1,490 @@ +addAttributes(['class' => ['custom-properties-form']]); + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $inheritedVars = json_decode(json_encode($this->object->getInheritedVars()), JSON_OBJECT_AS_ARRAY); + $origins = $this->object->getOriginsVars(); + + /** @var SubmitElement $submitButton */ + $submitButton = $this->createElement('submit', 'save', [ + 'label' => $this->translate('Save') + ]); + + $this->registerElement($submitButton); + + $duplicateSubmit = $this->duplicateSubmitButton($submitButton); + + $this->addElement($duplicateSubmit); + + foreach ($this->objectProperties as $objectProperty) { + $inheritedVar = []; + if (isset($inheritedVars[$objectProperty['key_name']])) { + $inheritedVar = [$inheritedVars[$objectProperty['key_name']], $origins->{$objectProperty['key_name']}]; + } + + $this->preparePropertyElement($objectProperty, inheritedValue: $inheritedVar); + } + + $this->addElement( + $submitButton + ); + } + + protected function preparePropertyElement( + array $objectProperty, + FieldsetElement $parentElement = null, + $inheritedValue = [] + ): void { + $isInstantiable = $objectProperty['instantiable'] === 'y'; + $fieldType = $this->fetchFieldType($objectProperty['value_type'], $isInstantiable); + $placeholder = ''; + if ($inheritedValue && ! (is_array($inheritedValue[0]) || $objectProperty['value_type'] === 'bool')) { + $placeholder = $inheritedValue[0] + . sprintf($this->translate(' (inherited from "%s")'), $inheritedValue[1]); + } + + $fieldName = $objectProperty['key_name']; + if ($parentElement) { + $fieldName = is_numeric($fieldName) + ? 'item-' . $fieldName + : $fieldName; + } + + if ($fieldType === 'boolean') { +// $options = ['' => sprintf(' - %s - ', $this->translate('Please choose')), 'y' => 'Yes', 'n' => 'No']; + +// if (! empty($inheritedValue)) { +// $options[''] = ($inheritedValue[0] === 'y' ? 'Yes' : 'No') +// . sprintf($this->translate(' (inherited from "%s")'), $inheritedValue[1]); +// } + + $field = new IplBoolean($fieldName, ['label' => $objectProperty['label'],'decorator' => ['ViewHelper']]); + } elseif ($fieldType === 'extensibleSet') { + $placeholder = ! empty($inheritedValue[0]) + ? implode(', ', $inheritedValue[0]) + . sprintf($this->translate(' (inherited from "%s")'), $inheritedValue[1]) + : ''; + $field = (new ArrayElement($fieldName, [ + 'label' => $objectProperty['label'] + ])) + ->setPlaceholder($placeholder) + ->setVerticalTermDirection(); + } elseif ($fieldType === 'collection') { + $field = new FieldsetElement($fieldName, [ + 'label' => $objectProperty['label'], + 'class' => ['dictionary-element'] + ]); + } else { + $field = $this->createElement( + $fieldType, + $fieldName, + [ + 'label' => $objectProperty['label'], + 'placeholder' => $placeholder + ], + ); + } + + if ($parentElement) { + $parentElement->addElement($field); + } else { + $this->addElement($field); + } + + if ($field instanceof FieldsetElement) { + $propertyItems = $this->fetchPropertyItems(Uuid::fromBytes($objectProperty['uuid'])); + + if (! $isInstantiable) { + foreach ($propertyItems as $propertyItem) { + $propertyInherited = []; + if (isset($inheritedValue[0][$propertyItem['key_name']])) { + $propertyInherited = [$inheritedValue[0][$propertyItem['key_name']], $inheritedValue[1]]; + } + + $this->preparePropertyElement( + $propertyItem, + $field, + $propertyInherited + ); + } + } elseif ($objectProperty['value_type'] === 'dict') { + /** @var SubmitButtonElement $addItem */ + $addItem = $this->createElement( + 'submitButton', + 'add-item', + [ + 'label' => $this->translate('Add Item'), + 'formnovalidate' => true + ], + ); + + $initialCountElement = $this->createElement( + 'hidden', + 'initial-count' + ); + + $addedCountElement = $this->createElement( + 'hidden', + 'added-count', + ['class' => 'autosubmit'], + ); + + $field->addElement($initialCountElement); + $field->addElement($addedCountElement); + $field->registerElement($addItem); + $this->registerElement($field); + + $addedItemsCount = (int) $addedCountElement->getValue(); + $initialItemsCount = (int) $initialCountElement->getValue(); + + $prefixElement = $this->createElement( + 'hidden', + 'prefixes' + ); + + $field->addElement($prefixElement); + + $prefixes = $prefixElement->getValue() !== null + ? explode(',', (string) $prefixElement->getValue()) + : []; + + foreach ($prefixes as $idx => $prefix) { + $propertyField = new FieldsetElement( + 'property-' . $prefix, + ['class' => ['dictionary-item', 'dictionary-element']] + ); + $field->addElement($propertyField); + + $propertyItemLabel = $this->createElement( + 'text', + 'label', + [ + 'label' => $this->translate('Item Label'), + 'required' => true, + 'class' => 'autosubmit' + ], + ); + + $propertyField->addElement($propertyItemLabel); + $field->registerElement($propertyField); + $propertyField->setLabel($propertyItemLabel->getValue()); + foreach ($propertyItems as $propertyItem) { + $inheritedVar = []; + if (! empty($inheritedValue) && isset($inheritedValue[0][$propertyItemLabel->getValue()])) { + $inheritedVar = [$inheritedValue[0][$propertyItemLabel->getValue()], $inheritedValue[1]]; + } + + $this->preparePropertyElement( + $propertyItem, + $propertyField, + $inheritedVar + ); + } + + /** @var SubmitButtonElement $removeItem */ + $removeItem = $this->createElement( + 'submitButton', + "remove-item", + [ + 'class' => 'remove-button', + 'label' => new Icon('minus', ['title' => 'Remove item']), + 'value' => $prefix, + 'formnovalidate' => true + ] + ); + + $propertyField->registerElement($removeItem); + $propertyField->addHtml($removeItem); + if ($removeItem->hasBeenPressed()) { + $field->remove($propertyField); + unset($prefixes[$idx]); + $initialItemsCount -= 1; + $initialCountElement->setValue($initialItemsCount); + $prefixElement->setValue(implode(',', $prefixes)); + } + } + + if ($addItem->hasBeenPressed()) { + $addedItemsCount += 1; + $addedCountElement->setValue($addedItemsCount); + } + + $removedItems = 0; + $removedItemIdx = null; + for ( + $numberItem = $initialItemsCount; + $numberItem < ($addedItemsCount + $initialItemsCount); + $numberItem++ + ) { + $tempNumberItem = $numberItem; + if ($removedItems > 0 && $removedItemIdx < $numberItem) { + $tempNumberItem = $numberItem - 1; + } + + $idx = $tempNumberItem - $initialItemsCount; + + $propertyField = new FieldsetElement('property-' . $tempNumberItem, [ + 'label' => $this->translate('New Property') . " $idx", + 'class' => ['dictionary-item', 'dictionary-element'] + ]); + + $field->addElement($propertyField); + $propertyItemLabel = $this->createElement( + 'text', + 'label', + [ + 'label' => $this->translate('Item Label'), + 'required' => true + ], + ); + + $propertyField->addElement($propertyItemLabel); + foreach ($propertyItems as $propertyItem) { + $this->preparePropertyElement($propertyItem, $propertyField); + } + + $removeItem = $this->createElement( + 'submitButton', + "remove-item", + [ + 'class' => ['remove-button', 'autosubmit'], + 'label' => new Icon('minus', ['title' => 'Remove item']), + 'value' => $tempNumberItem, + 'formnovalidate' => true + ] + ); + + $propertyField->registerElement($removeItem); + $propertyField->addHtml($removeItem); + if (! $removedItems && $removeItem->hasBeenPressed()) { + $field->remove($propertyField); + $removedItemIdx = (int) $removeItem->getValue(); + $removedItems += 1; + } else { + $propertyField->populate($field->getPopulatedValue('property-' . $numberItem) ?? []); + } + } + + $addedItemsCount -= $removedItems; + $addedCountElement->setValue($addedItemsCount); + + if ($addedItemsCount === 0 && $initialItemsCount === 0 && ! empty($inheritedValue)) { + $field->addElement( + 'textarea', + 'inherited-value', + [ + 'label' => sprintf($this->translate('Inherited from "%s"'), $inheritedValue[1]), + 'value' => json_encode($inheritedValue[0], JSON_PRETTY_PRINT), + 'readonly' => true, + 'rows' => 10 + ] + ); + } + + $field->addElement($addItem); + } + } + } + + private function fetchPropertyItems(UuidInterface $parentUuid): ?array + { + $db = $this->db->getDbAdapter(); + $query = $db->select() + ->from('director_property') + ->where('parent_uuid = ?', $parentUuid->getBytes()); + + return $db->fetchAll($query, fetchMode: PDO::FETCH_ASSOC); + } + + protected function fetchFieldType(string $propertyType, bool $instantiable = false): string + { + // works only in PHP 8.0 and greater + return match ($propertyType) { + 'bool' => 'boolean', + 'array' => $instantiable + ? 'extensibleSet' + : 'collection', + 'dict' => 'collection', + default => 'text', + }; + } + + private function isPropertyInstantiable(string $name): string + { + return isset($this->objectProperties[$name]) ? $this->objectProperties[$name]['instantiable'] : 'n'; + } + + public function getValues() + { + $values = []; + foreach ($this->getElements() as $element) { + if (! $element->isIgnored()) { + $value = $element->getValue(); + if ($element instanceof FieldsetElement) { + foreach ($value as $key => $item) { + if (in_array($key, ['initial-count', 'added-count', 'prefixes', 'inherited-value'], true)) { + unset($value[$key]); + } elseif (substr($key, 0, strlen('item')) === 'item') { + $idx = (int) substr($key, -1); + $value[$idx] = $value[$key]; + unset($value[$key]); + } elseif (substr($key, 0, strlen('property')) === 'property') { + $label = $value[$key]['label'] ?? ''; + unset($value[$key]['label']); + + $value[$label] = $value[$key]; + unset($value[$key]); + } + } + } + + $values[$element->getName()] = $value; + } + } + + return $values; + } + + public function populate($values) + { + foreach ($values as $name => $value) { + $newValues = []; + if (is_array($value)) { + if ( + $this->isPropertyInstantiable($name) === 'y' + && array_keys($value) === range(0, count($value) - 1) + ) { + $values[$name] = implode(',', $value); + } elseif ($this->isPropertyInstantiable($name) === 'y') { + $nestedValues = []; + $i = 0; + $prefixes = []; + foreach ($value as $key => $item) { + if (! is_array($item)) { + break; + } else { + $nestedValues["property-$i"] = array_merge( + ['label' => $key], + $item, + ); + + $prefixes[] = $i; + $i += 1; + } + } + + if (! empty($nestedValues)) { + $nestedValues['initial-count'] = $i; + $nestedValues['prefixes'] = implode(',', $prefixes); + unset($values[$name]); + + $values[$name] = $nestedValues; + } elseif (array_keys($value) === range(0, count($value) - 1)) { + $newValue = []; + foreach (array_values($value) as $idx => $item) { + $newValue["item-$idx"] = $item; + } + + $values[$name] = $newValue; + } + } elseif (array_keys($value) === range(0, count($value) - 1)) { + $newValue = []; + foreach (array_values($value) as $idx => $item) { + $newValue["item-$idx"] = $item; + } + + $values[$name] = $newValue; + } + } + } + + return parent::populate($values); + } + + private function filterEmpty(array $array): array + { + return array_filter( + array_map(function ($item) { + if (! is_array($item)) { + // Recursively clean nested arrays + return $item; + } + + return $this->filterEmpty($item); + }, $array), + function ($item) { + return is_bool($item) || ! empty($item); + } + ); + } + + protected function onSuccess() + { + $vars = $this->object->vars(); + + $modified = false; + var_dump($this->getValues());die; + foreach ($this->getValues() as $key => $value) { + if (is_array($value)) { + $value = $this->filterEmpty($value); + } + + if (! is_bool($value) && empty($value)) { + $vars->set($key, null); + } else { + $vars->set($key, $value); + } + + if ($modified === false && $vars->hasBeenModified()) { + $modified = true; + } + } + + $vars->storeToDb($this->object); + + if ($modified) { + Notification::success( + sprintf( + $this->translate('Custom variables have been successfully modified for %s'), + $this->object->getObjectName(), + ), + ); + } else { + Notification::success($this->translate('There is nothing to change.')); + } + } +} diff --git a/application/forms/ObjectPropertyForm.php b/application/forms/ObjectPropertyForm.php new file mode 100644 index 00000000..bdd67f51 --- /dev/null +++ b/application/forms/ObjectPropertyForm.php @@ -0,0 +1,172 @@ +properties = $this->getProperties(); + $this->objectProperties = $this->getProperties($this->object->uuid); + } + + public function getPropertyName(): string + { + $propertyUuid = $this->getValue('property'); + if ($propertyUuid) { + return $this->properties[$propertyUuid] ?? ''; + } + + return ''; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $propertyElement = $this->createElement( + 'select', + 'property', + [ + 'label' => $this->translate('Property'), + 'required' => true, + 'class' => ['autosubmit'], + 'disabledOptions' => [''], + 'value' => '', + 'options' => array_merge( + ['' => $this->translate('Please choose a property')], + $this->isRemoval + ? $this->getProperties($this->object->uuid) + : $this->getProperties() + ) + ] + ); + + $this->addElement($propertyElement); + + if (! $this->isRemoval) { + $propertyElement->addAttributes( + [ + 'validators' => [new CallbackValidator(function ($value, $validator) { + if (array_key_exists($value, $this->objectProperties)) { + $validator->addMessage($this->translate('Property already exists')); + + return false; + } + + return true; + })] + ] + ); + + $this->addElement( + 'select', + 'mandatory', + [ + 'label' => $this->translate('Mandatory'), + 'required' => true, + 'value' => 'n', + 'options' => ['y' => 'Yes', 'n' => 'No'] + ] + ); + } + + $this->addElement('submit', 'submit', [ + 'label' => $this->isRemoval + ? $this->translate('Remove') + : $this->translate('Add') + ]); + } + + protected function getProperties(?string $objectUuid = null): array + { + $query = $this->db->getDbAdapter() + ->select() + ->from(['dp' => 'director_property'], ['uuid' => 'dp.uuid', 'key_name' => 'dp.key_name']) + ->where('parent_uuid IS NULL'); + + if ($objectUuid) { + $query->join(['iop' => 'icinga_host_property'], 'iop.property_uuid = dp.uuid') + ->where('iop.host_uuid = ?', $objectUuid); + } + + $properties = $this->db->getDbAdapter()->fetchAll($query); + + $propUuidKeyPairs = []; + foreach ($properties as $property) { + $propUuidKeyPairs[Uuid::fromBytes($property->uuid)->toString()] = $property->key_name; + } + + return $propUuidKeyPairs; + } + + protected function onSuccess() + { + $formProperty = $this->getValue('property'); + if ($this->isRemoval) { + $varName = $this->getPropertyName(); + $propertyAsHostVar = $this->db->getDbAdapter()->fetchAll( + $this->db + ->getDbAdapter() + ->select() + ->from('icinga_host_var') + ->where('varname = ?', $varName) + ); + + $objectId = (int) $this->object->get('id'); + $objectsToCleanUp = [$objectId]; + foreach ($propertyAsHostVar as $propertyAsHostVarRow) { + $host = IcingaHost::loadWithAutoIncId($propertyAsHostVarRow->host_id, $this->db); + + if (in_array($objectId, $host->listAncestorIds(), true)) { + $objectsToCleanUp[] = (int) $host->get('id'); + } + } + + $where = $this->db->getDbAdapter()->quoteInto('varname = ?', $varName); + $where .= $this->db->getDbAdapter()->quoteInto(' AND host_id IN (?)', $objectsToCleanUp); + $this->db->getDbAdapter() + ->delete('icinga_host_var', $where); + + $this->db->delete( + 'icinga_host_property', + Filter::matchAll( + Filter::where('host_uuid', $this->object->uuid), + Filter::where('property_uuid', Uuid::fromString($formProperty)->getBytes()) + ) + ); + + return; + } + + $this->db->insert( + 'icinga_host_property', + [ + 'host_uuid' => $this->object->uuid, + 'property_uuid' => Uuid::fromString($this->getValue('property'))->getBytes() + ] + ); + } +} diff --git a/application/forms/PropertyForm.php b/application/forms/PropertyForm.php new file mode 100644 index 00000000..ae9e4fbe --- /dev/null +++ b/application/forms/PropertyForm.php @@ -0,0 +1,276 @@ +uuid; + } + + public function getParentUUid(): ?UuidInterface + { + return $this->parentUuid; + } + + public function setHideKeyNameElement(bool $hideKeyNameElement): self + { + $this->hideKeyNameElement = $hideKeyNameElement; + + return $this; + } + + public function setIsNestedField(bool $isNestedField): self + { + $this->isNestedField = $isNestedField; + + return $this; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + + if ($this->hideKeyNameElement) { + $db = $this->db->getDbAdapter(); + + $query = $db->select() + ->from('director_property', ['count' => 'COUNT(*)']) + ->where('parent_uuid = ?', $this->parentUuid->getBytes()); + $this->addElement( + 'hidden', + 'key_name', + [ + 'label' => $this->translate('Key'), + 'required' => true, + 'value' => $db->fetchOne($query) + ] + ); + } else { + $this->addElement( + 'text', + 'key_name', + [ + 'label' => $this->translate('Key'), + 'required' => true + ] + ); + } + + $this->addElement( + 'text', + 'label', + [ + 'label' => $this->translate('Label'), + 'required' => true + ] + ); + + $types = [ + 'string' => 'String', + 'number' => 'Number', + 'bool' => 'Boolean', + ]; + + if (! $this->isNestedField) { + $types += ['array' => 'Array', 'dict' => 'Dictionary']; + } + + $this->addElement( + 'select', + 'value_type', + [ + 'label' => $this->translate('Type'), + 'class' => 'autosubmit', + 'required' => true, + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => $types + ] + ); + + $type = $this->getValue('value_type'); + if ($type === 'dict' || $type === 'array') { + $instantiableElement = $this->createElement( + 'checkbox', + 'instantiable', + [ + 'label' => $this->translate('Instantiable by users'), + 'class' => 'autosubmit', + 'checkedValue' => 'y', + 'uncheckedValue' => 'n', + 'value' => 'n' + ] + ); + + if ($type === 'dict') { + $instantiableElement->getAttributes()->add('disabled', $this->parentUuid !== null); + } + + $this->addElement($instantiableElement); + + + if ($type === 'array' && $this->getValue('instantiable') === 'y') { + $this->addElement( + 'select', + 'item_type', + [ + 'label' => $this->translate('Item Type'), + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => 'string', + 'options' => array_slice($types, 0, 2) + ] + ); + } + } + + $this->addElement('submit', 'submit', [ + 'label' => $this->uuid ? $this->translate('Save') : $this->translate('Add') + ]); + + if ($this->uuid) { + /** @var FormSubmitElement $deleteButton */ + $deleteButton = $this->createElement( + 'submit', + 'delete', + [ + 'label' => $this->translate('Delete'), + 'class' => 'btn-remove', + 'formnovalidate' => true + ] + ); + + $this->registerElement($deleteButton); + $this->getElement('submit') + ->getWrapper() + ->prepend($deleteButton); + } + } + + public function hasBeenSubmitted() + { + if ($this->getPressedSubmitElement() !== null && $this->getPressedSubmitElement()->getName() === 'delete') { + return true; + } + + return parent::hasBeenSubmitted(); + } + + public function isValid() + { + if ($this->getPressedSubmitElement()->getName() === 'delete') { + $csrfElement = $this->getElement('CSRFToken'); + + return $csrfElement->isValid(); + } + + return parent::isValid(); + } + + protected function onSuccess() + { + if ($this->getPressedSubmitElement()->getName() === 'delete') { + $this->db->delete('director_property', Filter::where('parent_uuid', $this->uuid->getBytes())); + $this->db->delete('director_property', Filter::where('uuid', $this->uuid->getBytes())); + + return; + } + + $values = $this->getValues(); + if ($this->uuid === null) { + $this->uuid = Uuid::uuid4(); + if ($this->field) { + $values = array_merge( + [ + 'uuid' => $this->uuid->getBytes(), + 'parent_uuid' => $this->parentUuid->getBytes() + ], + $values + ); + } else { + $values = array_merge( + ['uuid' => $this->uuid->getBytes()], + $values + ); + } + + $instantiatedEntry = []; + if (isset($values['item_type'])) { + $instantiatedEntry = [ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'value_type' => $values['item_type'], + 'parent_uuid' => $this->uuid->getBytes(), + 'instantiable' => 'n', + ]; + + unset($values['item_type']); + } + + $this->db->insert('director_property', $values); + + if (! empty($instantiatedEntry)) { + $this->db->insert('director_property', $instantiatedEntry); + } + } else { + $instantiatedEntry = []; + if (isset($values['item_type']) && $values['instantiable'] === 'y') { + $instantiatedEntry = [ + 'uuid' => Uuid::uuid4()->getBytes(), + 'key_name' => '0', + 'value_type' => $values['item_type'], + 'parent_uuid' => $this->uuid->getBytes(), + 'instantiable' => 'n', + ]; + + unset($values['item_type']); + } + + $this->db->update( + 'director_property', + $values, + Filter::where('uuid', $this->uuid->getBytes()) + ); + + $this->db->delete( + 'director_property', + Filter::matchAll( + Filter::where('parent_uuid', $this->uuid->getBytes()), + Filter::where('key_name', '0') + ) + ); + + if (! empty($instantiatedEntry)) { + $this->db->insert('director_property', $instantiatedEntry); + } + } + } +} diff --git a/configuration.php b/configuration.php index f812f3c4..c5d42564 100644 --- a/configuration.php +++ b/configuration.php @@ -175,3 +175,9 @@ $section->add(N_('Deployments')) ->setUrl('director/config/deployments') ->setPriority(902) ->setPermission(Permission::DEPLOYMENTS); +$section->add(N_('Properties')) + ->setUrl('director/properties') + ->setPriority(903); + +$this->provideCssFile('property-table.less'); +$this->provideCssFile('custom-properties-form.less'); diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index 88e38a45..f98c3d5c 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -10,7 +10,6 @@ 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\BranchSupport; use Icinga\Module\Director\Db\Branch\UuidLookup; use Icinga\Module\Director\Deployment\DeploymentInfo; use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; @@ -18,6 +17,7 @@ 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\Forms\ObjectPropertyForm; use Icinga\Module\Director\Objects\IcingaCommand; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Objects\IcingaObjectGroup; @@ -34,7 +34,10 @@ 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 Icinga\Module\Director\Web\Widget\ObjectPropertyTable; +use Icinga\Web\Notification; use ipl\Html\Html; +use ipl\Web\Url; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -101,6 +104,10 @@ abstract class ObjectController extends ActionController protected function initializeWebRequest() { + if ($this->getRequest()->getActionName() === 'add-property') { + return; + } + if ($this->getRequest()->getActionName() === 'add') { $this->addSingleTab( sprintf($this->translate('Add %s'), ucfirst($this->getType())), @@ -268,6 +275,76 @@ abstract class ObjectController extends ActionController } } + public function addPropertyAction() + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + $this->view->title = sprintf($this->translate('Add Custom Property: %s'), $this->object->getObjectName()); + try { + $this->fetchPropertyForm($object); + } catch (NestingError $e) { + $this->content()->add(Hint::error($e->getMessage())); + } + } + + public function removePropertyAction() + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + $this->view->title = sprintf($this->translate('Remove Custom Property: %s'), $this->object->getObjectName()); + try { + $this->fetchPropertyForm($object, true); + } catch (NestingError $e) { + $this->content()->add(Hint::error($e->getMessage())); + } + } + + protected function fetchPropertyForm(IcingaObject $object, bool $isRemoval = false) + { + $propertyUuid = $this->params->get('property_uuid'); + $objectUuid = $this->object->get('uuid'); + $objectType = $this->object->getShortTableName(); + $formData = []; + + if ($propertyUuid) { + $propertyUuid = Uuid::fromString($propertyUuid); + $objectPropertyQuery = $this->db() + ->select() + ->from('icinga_host_property', ['required']) + ->where($objectType . '_uuid ', $objectUuid) + ->where('property_uuid', $propertyUuid); + + $formData = [ + 'property' => $propertyUuid->toString(), + 'required' => $this->db()->fetchOne($objectPropertyQuery) + ]; + } + + $form = (new ObjectPropertyForm($this->db(), $object, $isRemoval, $propertyUuid)) + ->populate($formData) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(ObjectPropertyForm::ON_SUCCESS, function (ObjectPropertyForm $form) use ($objectUuid, $isRemoval) { + if ($isRemoval) { + Notification::success(sprintf( + $this->translate('Property %s has successfully been deleted'), + $form->getPropertyName() + )); + } else { + Notification::success(sprintf( + sprintf($this->translate('Property%s has successfully been added'), $form->getPropertyName()) + )); + } + + $this->redirectNow(Url::fromPath( + 'director/' . $this->getType() . '/variables', + ['uuid' => UUid::fromBytes($objectUuid)->toString()] + )); + }) + ->handleRequest($this->getServerRequest()); + + $this->content()->add($form); + } + protected function addFieldsFormAndTable($object, $type) { $form = IcingaObjectFieldForm::load() diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php index 62e539ce..a0bdce8e 100644 --- a/library/Director/Web/Form/DirectorObjectForm.php +++ b/library/Director/Web/Form/DirectorObjectForm.php @@ -15,6 +15,7 @@ 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\IcingaHost; use Icinga\Module\Director\Objects\IcingaTemplateChoice; use Icinga\Module\Director\Objects\IcingaCommand; use Icinga\Module\Director\Objects\IcingaObject; @@ -820,14 +821,24 @@ abstract class DirectorObjectForm extends DirectorForm if ($this->object !== null) { $this->setDefaultsFromObject($this->object); } - $this->prepareFields($this->object()); + + $isHost = $this->object instanceof IcingaHost; + + if (! $isHost) { + $this->prepareFields($this->object()); + } + IcingaObjectFormHook::callOnSetup($this); if ($this->hasBeenSent()) { $this->handlePost(); } + try { $this->loadInheritedProperties(); - $this->addFields(); + if (! $isHost) { + $this->addFields(); + } + $this->callOnRequestCallables(); } catch (Exception $e) { $this->addUniqueException($e); @@ -848,7 +859,7 @@ abstract class DirectorObjectForm extends DirectorForm $this->populate($post); $values = $this->getValues(); - if ($object instanceof IcingaObject) { + if ($object instanceof IcingaObject && $object->getShortTableName() !== 'host') { $this->setCustomVarValues($post); } diff --git a/library/Director/Web/Form/Element/ArrayElement.php b/library/Director/Web/Form/Element/ArrayElement.php new file mode 100644 index 00000000..277c333b --- /dev/null +++ b/library/Director/Web/Form/Element/ArrayElement.php @@ -0,0 +1,71 @@ + 'array-input']; + + public function setPlaceHolder(string $placeHolder): static + { + $this->placeHolder = $placeHolder; + + return $this; + } + + protected function assemble() + { + parent::assemble(); + + $valuePlaceHolder = $this->translate('Separate multiple values by comma.'); + if ($this->placeHolder) { + $valuePlaceHolder = $this->placeHolder . '. ' . $valuePlaceHolder; + } + + $this->getElement('value') + ->getAttributes() + ->registerAttributeCallback('placeholder', function () use ($valuePlaceHolder) { + return $valuePlaceHolder; + }); + } + + public function getValue($name = null, $default = null) + { + if ($name !== null) { + return parent::getValue($name, $default); + } + + $terms = []; + foreach ($this->getTerms() as $term) { + $terms[] = $term->render(','); + } + + return $terms; + } + + public function setValue($value) + { + if (is_array($value) && isset($value['value'])) { + $separatedTerms = $value['value'] ?? ''; + parent::setValue($value); + } elseif (is_array($value)) { + $separatedTerms = implode(',', $value); + } else { + $separatedTerms = $value; + } + + $terms = []; + foreach ($this->parseValue((string) $separatedTerms) as $term) { + $terms[] = new RegisteredTerm($term); + } + + return $this->setTerms(...$terms); + } +} diff --git a/library/Director/Web/Form/Element/IplBoolean.php b/library/Director/Web/Form/Element/IplBoolean.php new file mode 100644 index 00000000..474644c2 --- /dev/null +++ b/library/Director/Web/Form/Element/IplBoolean.php @@ -0,0 +1,64 @@ + $this->translate('Yes'), + 'n' => $this->translate('No'), + ]; + if (! $this->isRequired()) { + $options = [ + null => $this->translate('- Please choose -'), + ] + $options; + } + + $this->setOptions($options); + } + + public function setValue($value) + { + if ($value === 'y' || $value === true) { + return parent::setValue('y'); + } elseif ($value === 'n' || $value === false) { + return parent::setValue('n'); + } + + // Hint: this will fail + return parent::setValue($value); + } + + public function getValue() + { + if ($this->value === 'y') { + return true; + } elseif ($this->value === 'n') { + return false; + } + + return $this->value; + } + + protected function isSelectedOption($optionValue): bool + { + $optionValue = match ($optionValue) { + 'y' => true, + 'n' => false, + default => null + }; + + return parent::isSelectedOption( + $optionValue + ); + } +} diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php index e9142367..dac713d2 100644 --- a/library/Director/Web/Tabs/ObjectTabs.php +++ b/library/Director/Web/Tabs/ObjectTabs.php @@ -93,7 +93,15 @@ class ObjectTabs extends Tabs )); } - if ($auth->hasPermission(Permission::ADMIN) && $this->hasFields()) { + if ($this->object->getShortTableName() === 'host') { + if ($auth->hasPermission(Permission::ADMIN)) { + $this->add('variables', array( + 'url' => sprintf('director/%s/variables', $type), + 'urlParams' => $params, + 'label' => $this->translate('Custom Variables') + )); + } + } elseif ($auth->hasPermission(Permission::ADMIN) && $this->hasFields()) { $this->add('fields', array( 'url' => sprintf('director/%s/fields', $type), 'urlParams' => $params, diff --git a/library/Director/Web/Widget/ObjectPropertyTable.php b/library/Director/Web/Widget/ObjectPropertyTable.php new file mode 100644 index 00000000..65251807 --- /dev/null +++ b/library/Director/Web/Widget/ObjectPropertyTable.php @@ -0,0 +1,57 @@ + 'common-table table-row-selectable object-property-table' + ]; + + public function __construct( + protected UuidInterface $objectUuid, + protected array $properties + ) { + } + + protected function assemble() + { + $this->add(static::tr([ + static::th([HtmlElement::create('p', null, $this->translate('Key'))])->setSeparator(' '), + static::th([HtmlElement::create('p', null, $this->translate('Label'))])->setSeparator(' '), + static::th([HtmlElement::create('p', null, $this->translate('Type'))]), + static::th([HtmlElement::create('p', null, $this->translate('Mandatory'))]) + ])); + foreach ($this->properties as $property) { + $objectPropertyLink = new Link( + $property->key_name, + Url::fromPath( + 'director/host/properties', + [ + 'uuid' => $this->objectUuid->toString(), + 'property_uuid' => Uuid::fromBytes($property->uuid)->toString() + ] + ), + ['target' => '_blank'] + ); + + $this->add(static::tr([ + static::td([HtmlElement::create('p', null, $objectPropertyLink)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->label)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->value_type)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->required)]) + ])); + } + } +} diff --git a/library/Director/Web/Widget/PropertyTable.php b/library/Director/Web/Widget/PropertyTable.php new file mode 100644 index 00000000..80ba6cd5 --- /dev/null +++ b/library/Director/Web/Widget/PropertyTable.php @@ -0,0 +1,50 @@ + 'common-table table-row-selectable property-table', + 'data-base-target' => '_next', + ]; + + public function __construct( + protected array $properties, + protected bool $isFieldsTable = false + ) { + } + + protected function assemble() + { + foreach ($this->properties as $property) { + if ($this->isFieldsTable) { + $url = Url::fromPath( + 'director/property/edit-field', + [ + 'uuid' => Uuid::fromBytes($property->uuid)->toString(), + 'parent_uuid' => Uuid::fromBytes($property->parent_uuid)->toString() + ] + ); + } else { + $url = Url::fromPath( + 'director/property', + ['uuid' => Uuid::fromBytes($property->uuid)->toString()] + ); + } + + $this->add(static::tr([ + static::td([HtmlElement::create('strong', null, new Link($property->key_name, $url))]) + ->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->label)])->setSeparator(' '), + static::td([HtmlElement::create('p', null, $property->value_type)]) + ])); + } + } +} diff --git a/public/css/custom-properties-form.less b/public/css/custom-properties-form.less new file mode 100644 index 00000000..87d6600b --- /dev/null +++ b/public/css/custom-properties-form.less @@ -0,0 +1,29 @@ +.custom-properties-form { + .control-group:has(> fieldset) { + position: relative; + padding-right: 2em; + + fieldset:not(.array-input) { + border: 1px solid @gray-light; + padding: 1em; + margin-bottom: 1em; + &.dictionary-item legend { + background-color: @icinga-blue-light; + width: 100%; + } + + .remove-button { + position: absolute; + top: 0; + right: 2em; + width: 2em; + border: none; + justify-content: center; + color: @color-critical; + &:not(:hover) { + background-color: @low-sat-blue; + } + } + } + } +} \ No newline at end of file diff --git a/public/css/module.less b/public/css/module.less index 51c4ec25..8da1a0a9 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -490,6 +490,10 @@ form.director-form .host-group-links { text-decoration: line-through; } +details { + width: 100%; +} + // TODO: figure out whether form.editor and filter-related CSS is still required div.filter > form.search, div.filter > a { // Duplicated by quicksearch diff --git a/public/css/property-table.less b/public/css/property-table.less new file mode 100644 index 00000000..247022ba --- /dev/null +++ b/public/css/property-table.less @@ -0,0 +1,3 @@ +.common-table.property-table { + max-width: 100%; +} \ No newline at end of file diff --git a/public/js/module.js b/public/js/module.js index 07fe265d..993d5b84 100644 --- a/public/js/module.js +++ b/public/js/module.js @@ -20,6 +20,7 @@ this.module.on('rendered', this.rendered); this.module.on('beforerender', this.beforeRender); this.module.on('click', 'fieldset > legend', this.toggleFieldset); + // this.module.on('click', 'fieldset > legend', this.toggleFieldset); // Disabled // this.module.on('click', 'div.controls ul.tabs a', this.detailTabClick); this.module.on('click', 'input.related-action', this.extensibleSetAction); @@ -731,7 +732,7 @@ url = $container.data('icingaUrl'); $actions = $('.main-actions', $('#col1')); } - if (! $actions.length) { + if ($actions) { return; } diff --git a/schema/mysql.sql b/schema/mysql.sql index 8052ecde..b146fb93 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -2444,6 +2444,33 @@ CREATE TABLE branched_icinga_dependency ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE director_property ( + uuid binary(16) NOT NULL, + parent_uuid binary(16) NULL DEFAULT NULL, + key_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + label varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, + value_type enum('string', 'number', 'bool', 'array', 'dict') COLLATE utf8mb4_unicode_ci NOT NULL, + instantiable enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (uuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE icinga_host_property ( + host_uuid binary(16) NOT NULL, + property_uuid binary(16) NOT NULL, + required enum('y', 'n') NOT NULL DEFAULT 'n', + PRIMARY KEY (host_uuid, property_uuid), + CONSTRAINT icinga_host_property_host + FOREIGN KEY host(host_uuid) + REFERENCES icinga_host (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT icinga_host_custom_property + FOREIGN KEY property(property_uuid) + REFERENCES director_property (uuid) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (189, NOW()); + VALUES (190, NOW());