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.
This commit is contained in:
raviks789 2025-03-11 09:27:04 +01:00
parent 6749b4fb08
commit c4f3792d2f
No known key found for this signature in database
19 changed files with 1808 additions and 42 deletions

View File

@ -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',
],
));
}

View File

@ -0,0 +1,67 @@
<?php
namespace Icinga\Module\Director\Controllers;
use Icinga\Application\Config;
use Icinga\Module\Director\Db;
use Icinga\Module\Director\Forms\PropertyForm;
use Icinga\Module\Director\Web\Widget\PropertyTable;
use Icinga\Web\Notification;
use ipl\Html\Html;
use ipl\Html\Text;
use ipl\Web\Compat\CompatController;
use ipl\Web\Url;
use ipl\Web\Widget\ButtonLink;
class PropertiesController extends CompatController
{
public function indexAction()
{
$this->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);
}
}

View File

@ -0,0 +1,241 @@
<?php
namespace Icinga\Module\Director\Controllers;
use Icinga\Application\Config;
use Icinga\Module\Director\Db;
use Icinga\Module\Director\Forms\PropertyForm;
use Icinga\Module\Director\Web\Widget\PropertyTable;
use Icinga\Web\Notification;
use ipl\Html\HtmlElement;
use ipl\Html\Text;
use ipl\Web\Compat\CompatController;
use ipl\Web\Url;
use ipl\Web\Widget\ButtonLink;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Zend_Db;
class PropertyController extends CompatController
{
/** @var Db */
protected $db;
public function init()
{
parent::init();
$this->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<string, mixed>
*/
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);
}
}

View File

@ -0,0 +1,490 @@
<?php
namespace Icinga\Module\Director\Forms;
use Icinga\Module\Director\Data\Db\DbConnection;
use Icinga\Module\Director\Objects\IcingaObject;
use Icinga\Module\Director\Web\Form\Element\ArrayElement;
use Icinga\Module\Director\Web\Form\Element\IplBoolean;
use Icinga\Web\Notification;
use Icinga\Web\Session;
use ipl\Html\FormElement\FieldsetElement;
use ipl\Html\FormElement\SubmitButtonElement;
use ipl\Html\FormElement\SubmitElement;
use ipl\I18n\Translation;
use ipl\Web\Common\CsrfCounterMeasure;
use ipl\Web\Compat\CompatForm;
use ipl\Web\FormElement\TermInput;
use ipl\Web\Widget\Icon;
use PDO;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class CustomPropertiesForm extends CompatForm
{
use CsrfCounterMeasure;
use Translation;
public function __construct(
public readonly DbConnection $db,
public readonly IcingaObject $object,
protected array $objectProperties = []
) {
$this->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.'));
}
}
}

View File

@ -0,0 +1,172 @@
<?php
namespace Icinga\Module\Director\Forms;
use Icinga\Module\Director\Data\Db\DbConnection;
use Icinga\Module\Director\Objects\IcingaHost;
use Icinga\Module\Director\Objects\IcingaObject;
use Icinga\Module\Director\Web\Form\Decorator\ExtensibleSetDecorator;
use Icinga\Web\Session;
use ipl\I18n\Translation;
use ipl\Validator\CallbackValidator;
use ipl\Web\Common\CsrfCounterMeasure;
use ipl\Web\Compat\CompatForm;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class ObjectPropertyForm extends CompatForm
{
use CsrfCounterMeasure;
use Translation;
protected $properties = [];
protected $objectProperties = [];
public function __construct(
public readonly DbConnection $db,
public readonly IcingaObject $object,
protected bool $isRemoval = false,
protected ?UuidInterface $propertyUuid = null
) {
$this->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()
]
);
}
}

View File

@ -0,0 +1,276 @@
<?php
namespace Icinga\Module\Director\Forms;
use Icinga\Data\Filter\Filter;
use Icinga\Module\Director\Data\Db\DbConnection;
use Icinga\Web\Session;
use ipl\Html\Contract\FormSubmitElement;
use ipl\I18n\Translation;
use ipl\Web\Common\CsrfCounterMeasure;
use ipl\Web\Compat\CompatForm;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class PropertyForm extends CompatForm
{
use CsrfCounterMeasure;
use Translation;
/** @var bool */
private $hideKeyNameElement = false;
private $isNestedField = false;
public function __construct(
protected DbConnection $db,
protected ?UuidInterface $uuid = null,
protected bool $field = false,
protected ?UuidInterface $parentUuid = null
) {
}
public function getUUid(): ?UuidInterface
{
return $this->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);
}
}
}
}

View File

@ -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');

View File

@ -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()

View File

@ -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);
}

View File

@ -0,0 +1,71 @@
<?php
namespace Icinga\Module\Director\Web\Form\Element;
use ipl\Html\Attribute;
use ipl\Web\FormElement\TermInput;
use ipl\Web\FormElement\TermInput\RegisteredTerm;
class ArrayElement extends TermInput
{
/** @var string */
private $placeHolder = '';
protected $defaultAttributes = ['class' => '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);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace Icinga\Module\Director\Web\Form\Element;
use ipl\Html\FormElement\SelectElement;
use ipl\I18n\Translation;
class IplBoolean extends SelectElement
{
use Translation;
public function __construct($name, $attributes = null)
{
parent::__construct($name, $attributes);
$options = [
'y' => $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
);
}
}

View File

@ -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,

View File

@ -0,0 +1,57 @@
<?php
namespace Icinga\Module\Director\Web\Widget;
use ipl\Html\Html;
use ipl\Html\HtmlElement;
use ipl\Html\Table;
use ipl\I18n\Translation;
use ipl\Web\Url;
use ipl\Web\Widget\Link;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class ObjectPropertyTable extends Table
{
use Translation;
protected $defaultAttributes = [
'class' => '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)])
]));
}
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Icinga\Module\Director\Web\Widget;
use ipl\Html\HtmlElement;
use ipl\Html\Table;
use ipl\Web\Url;
use ipl\Web\Widget\Link;
use Ramsey\Uuid\Uuid;
class PropertyTable extends Table
{
protected $defaultAttributes = [
'class' => '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)])
]));
}
}
}

View File

@ -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;
}
}
}
}
}

View File

@ -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

View File

@ -0,0 +1,3 @@
.common-table.property-table {
max-width: 100%;
}

View File

@ -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;
}

View File

@ -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());