summaryrefslogtreecommitdiffstats
path: root/application/controllers/SyncruleController.php
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
commitcd989f9c3aff968e19a3aeabc4eb9085787a6673 (patch)
treefbff2135e7013f196b891bbde54618eb050e4aaf /application/controllers/SyncruleController.php
parentInitial commit. (diff)
downloadicingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.tar.xz
icingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.zip
Adding upstream version 1.10.2.upstream/1.10.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'application/controllers/SyncruleController.php')
-rw-r--r--application/controllers/SyncruleController.php696
1 files changed, 696 insertions, 0 deletions
diff --git a/application/controllers/SyncruleController.php b/application/controllers/SyncruleController.php
new file mode 100644
index 0000000..928cf2c
--- /dev/null
+++ b/application/controllers/SyncruleController.php
@@ -0,0 +1,696 @@
+<?php
+
+namespace Icinga\Module\Director\Controllers;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Web\Widget\Hint;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchStore;
+use Icinga\Module\Director\Db\Branch\BranchSupport;
+use Icinga\Module\Director\Web\Controller\BranchHelper;
+use Icinga\Module\Director\Web\Form\ClickHereForm;
+use Icinga\Module\Director\Web\Table\BranchActivityTable;
+use Icinga\Module\Director\Web\Widget\IcingaConfigDiff;
+use Icinga\Module\Director\Web\Widget\UnorderedList;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Forms\SyncCheckForm;
+use Icinga\Module\Director\Forms\SyncPropertyForm;
+use Icinga\Module\Director\Forms\SyncRuleForm;
+use Icinga\Module\Director\Forms\SyncRunForm;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Import\Sync;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Web\ActionBar\AutomationObjectActionBar;
+use Icinga\Module\Director\Web\Controller\ActionController;
+use Icinga\Module\Director\Objects\SyncRule;
+use Icinga\Module\Director\Objects\SyncRun;
+use Icinga\Module\Director\Web\Form\CloneSyncRuleForm;
+use Icinga\Module\Director\Web\Table\SyncpropertyTable;
+use Icinga\Module\Director\Web\Table\SyncRunTable;
+use Icinga\Module\Director\Web\Tabs\SyncRuleTabs;
+use Icinga\Module\Director\Web\Widget\SyncRunDetails;
+use Icinga\Web\Notification;
+use ipl\Html\Form;
+use ipl\Html\Html;
+
+class SyncruleController extends ActionController
+{
+ use BranchHelper;
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function indexAction()
+ {
+ $this->setAutoRefreshInterval(10);
+ $rule = $this->requireSyncRule();
+ $this->tabs(new SyncRuleTabs($rule))->activate('show');
+ $ruleName = $rule->get('rule_name');
+ $this->addTitle($this->translate('Sync rule: %s'), $ruleName);
+
+ $checkForm = SyncCheckForm::load()->setSyncRule($rule)->handleRequest();
+ $store = new DbObjectStore($this->db(), $this->getBranch());
+ $runForm = new SyncRunForm($rule, $store);
+ $runForm->on(SyncRunForm::ON_SUCCESS, function (SyncRunForm $form) {
+ $message = $form->getSuccessMessage();
+ if ($message === null) {
+ Notification::error($this->translate('Synchronization failed'));
+ } else {
+ Notification::success($message);
+ }
+ $this->redirectNow($this->url());
+ });
+ $runForm->handleRequest($this->getServerRequest());
+
+ if ($lastRunId = $rule->getLastSyncRunId()) {
+ $run = SyncRun::load($lastRunId, $this->db());
+ } else {
+ $run = null;
+ }
+
+ $c = $this->content();
+ $c->add(Html::tag('p', null, $rule->get('description')));
+ if (! $rule->hasSyncProperties()) {
+ $this->addPropertyHint($rule);
+ return;
+ }
+ $this->addMainActions();
+ if (! $run) {
+ $c->add(Hint::warning($this->translate('This Sync Rule has never been run before.')));
+ }
+
+ switch ($rule->get('sync_state')) {
+ case 'unknown':
+ $c->add(Html::tag('p', null, $this->translate(
+ "It's currently unknown whether we are in sync with this rule."
+ . ' You should either check for changes or trigger a new Sync Run.'
+ )));
+ break;
+ case 'in-sync':
+ $c->add(Html::tag('p', null, sprintf(
+ $this->translate('This Sync Rule was last found to by in Sync at %s.'),
+ $rule->get('last_attempt')
+ )));
+ /*
+ TODO: check whether...
+ - there have been imports since then, differing from former ones
+ - there have been activities since then
+ */
+ break;
+ case 'pending-changes':
+ $c->add(Hint::warning($this->translate(
+ 'There are pending changes for this Sync Rule. You should trigger a new'
+ . ' Sync Run.'
+ )));
+ break;
+ case 'failing':
+ $c->add(Hint::error(sprintf(
+ $this->translate(
+ 'This Sync Rule failed when last checked at %s: %s'
+ ),
+ $rule->get('last_attempt'),
+ $rule->get('last_error_message')
+ )));
+ break;
+ }
+
+ $c->add($checkForm);
+ if ($this->hasBranch()) {
+ $objectType = $rule->get('object_type');
+ $table = DbObjectTypeRegistry::tableNameByType($objectType);
+ if (! BranchSupport::existsForTableName($table)) {
+ $this->showNotInBranch(sprintf($this->translate("Synchronizing '%s'"), $objectType));
+ return;
+ }
+ }
+
+ $c->add($runForm);
+
+ if ($run) {
+ $c->add(Html::tag('h3', null, $this->translate('Last sync run details')));
+ $c->add(new SyncRunDetails($run));
+ if ($run->get('rule_name') !== $ruleName) {
+ $c->add(Html::tag('p', null, sprintf(
+ $this->translate("It has been renamed since then, its former name was %s"),
+ $run->get('rule_name')
+ )));
+ }
+ }
+ }
+
+ /**
+ * @param SyncRule $rule
+ */
+ protected function addPropertyHint(SyncRule $rule)
+ {
+ $this->content()->add(Hint::warning(Html::sprintf(
+ $this->translate('You must define some %s before you can run this Sync Rule'),
+ new Link(
+ $this->translate('Sync Properties'),
+ 'director/syncrule/property',
+ ['rule_id' => $rule->get('id')]
+ )
+ )));
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function addAction()
+ {
+ $this->editAction();
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Exception
+ */
+ public function previewAction()
+ {
+ $rule = $this->requireSyncRule();
+ $branchSupport = BranchSupport::existsForSyncRule($rule);
+ $branchStore = new BranchStore($this->db());
+ $owner = $this->getAuth()->getUser()->getUsername();
+ if ($branchSupport) {
+ if ($this->getBranch()->isBranch()) {
+ $tmpBranchName = sprintf(
+ '%s/%s-%s',
+ Branch::PREFIX_SYNC_PREVIEW,
+ $this->getBranch()->getUuid()->toString(),
+ $rule->get('id')
+ );
+ // We could keep changes for preview on branch too
+ $branchStore->deleteByName($tmpBranchName);
+ $tmpBranch = $branchStore->cloneBranchForSync($this->getBranch(), $tmpBranchName, $owner);
+ $after = 1600000000; // a date in 2020, minus 10000000
+ } else {
+ $tmpBranchName = Branch::PREFIX_SYNC_PREVIEW . '/' . $rule->get('id');
+ $tmpBranch = $branchStore->fetchOrCreateByName($tmpBranchName, $owner);
+ $after = null;
+ }
+ $store = new DbObjectStore($this->db(), $tmpBranch);
+ } else {
+ $tmpBranch = $store = null;
+ }
+
+ $this->tabs(new SyncRuleTabs($rule))->activate('preview');
+ $this->addTitle($this->translate('Sync Preview'));
+ $sync = new Sync($rule, $store);
+ $keepBranchPreview = false;
+ if ($tmpBranch) {
+ if ($lastTime = $branchStore->getLastActivityTime($tmpBranch, $after)) {
+ if ((time() - $lastTime) > 100) {
+ $branchStore->wipeBranch($tmpBranch, $after);
+ } else {
+ $here = (new ClickHereForm())->handleRequest($this->getServerRequest());
+ if ($here->hasBeenClicked()) {
+ $branchStore->wipeBranch($tmpBranch, $after);
+ $this->redirectNow($this->url());
+ } else {
+ $keepBranchPreview = true;
+ }
+ $this->content()->add(Hint::info(Html::sprintf(
+ $this->translate('This preview has been generated %s, please click %s to regenerate it'),
+ DateFormatter::timeAgo($lastTime),
+ $here
+ )));
+ }
+ }
+ }
+ if (!$keepBranchPreview) {
+ $modifications = $sync->getExpectedModifications();
+ }
+
+ if ($tmpBranch) {
+ try {
+ if (!$keepBranchPreview) {
+ $sync->apply();
+ }
+ } catch (\Exception $e) {
+ $this->content()->add(Hint::error($e->getMessage()));
+ return;
+ }
+
+ $changes = new BranchActivityTable($tmpBranch->getUuid(), $this->db());
+ $changes->disableObjectLink();
+ if (count($changes) === 0) {
+ $this->showInSync();
+ }
+ $changes->renderTo($this);
+ } else {
+ if (empty($modifications)) {
+ $this->showInSync();
+ return;
+ }
+ $this->showExpectedModificationSummary($modifications);
+ }
+ }
+
+ protected function showInSync()
+ {
+ $this->content()->add(Hint::ok($this->translate(
+ 'This Sync Rule is in sync and would currently not apply any changes'
+ )));
+ }
+
+ protected function showExpectedModificationSummary($modifications)
+ {
+ $create = [];
+ $modify = [];
+ $delete = [];
+ $modifiedProperties = [];
+ /** @var IcingaObject $object */
+ foreach ($modifications as $object) {
+ if ($object->hasBeenLoadedFromDb()) {
+ if ($object->shouldBeRemoved()) {
+ $delete[] = $object;
+ } else {
+ $modify[] = $object;
+ foreach ($object->getModifiedProperties() as $property => $value) {
+ if (isset($modifiedProperties[$property])) {
+ $modifiedProperties[$property]++;
+ } else {
+ $modifiedProperties[$property] = 1;
+ }
+ }
+ if (! $object instanceof IcingaObject) {
+ continue;
+ }
+ if ($object->supportsGroups()) {
+ if ($object->hasModifiedGroups()) {
+ if (isset($modifiedProperties['groups'])) {
+ $modifiedProperties['groups']++;
+ } else {
+ $modifiedProperties['groups'] = 1;
+ }
+ }
+ }
+
+ if ($object->supportsImports()) {
+ if ($object->imports()->hasBeenModified()) {
+ if (isset($modifiedProperties['imports'])) {
+ $modifiedProperties['imports']++;
+ } else {
+ $modifiedProperties['imports'] = 1;
+ }
+ }
+ }
+ if ($object->supportsCustomVars()) {
+ if ($object->vars()->hasBeenModified()) {
+ foreach ($object->vars() as $var) {
+ if ($var->isNew()) {
+ $varName = 'add vars.' . $var->getKey();
+ } elseif ($var->hasBeenDeleted()) {
+ $varName = 'remove vars.' . $var->getKey();
+ } elseif ($var->hasBeenModified()) {
+ $varName = 'vars.' . $var->getKey();
+ } else {
+ continue;
+ }
+ if (isset($modifiedProperties[$varName])) {
+ $modifiedProperties[$varName]++;
+ } else {
+ $modifiedProperties[$varName] = 1;
+ }
+ }
+ }
+ }
+ }
+ } else {
+ $create[] = $object;
+ }
+ }
+
+ $content = $this->content();
+ if (! empty($delete)) {
+ $content->add([
+ Html::tag('h2', ['class' => 'icon-cancel action-delete'], sprintf(
+ $this->translate('%d object(s) will be deleted'),
+ count($delete)
+ )),
+ $this->objectList($delete)
+ ]);
+ }
+ if (! empty($modify)) {
+ $content->add([
+ Html::tag('h2', ['class' => 'icon-wrench action-modify'], sprintf(
+ $this->translate('%d object(s) will be modified'),
+ count($modify)
+ )),
+ $this->listModifiedProperties($modifiedProperties),
+ $this->objectList($modify),
+ ]);
+ }
+ if (! empty($create)) {
+ $content->add([
+ Html::tag('h2', ['class' => 'icon-plus action-create'], sprintf(
+ $this->translate('%d object(s) will be created'),
+ count($create)
+ )),
+ $this->objectList($create)
+ ]);
+ }
+ }
+
+ /**
+ * @param IcingaObject[] $objects
+ * @return \ipl\Html\HtmlElement
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function objectList($objects)
+ {
+ return Html::tag('p', $this->firstNames($objects));
+ }
+
+ /**
+ * Lots of duplicated code, this whole diff logic should be mouved to a
+ * dedicated class
+ *
+ * @param IcingaObject[] $objects
+ * @param int $max
+ * @return string
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function firstNames($objects, $max = 50)
+ {
+ $names = [];
+ $list = new UnorderedList();
+ $list->addAttributes([
+ 'style' => 'list-style-type: none; marign: 0; padding: 0',
+ ]);
+ $total = count($objects);
+ $i = 0;
+ PrefetchCache::forget();
+ IcingaHost::clearAllPrefetchCaches(); // why??
+ IcingaService::clearAllPrefetchCaches();
+ foreach ($objects as $object) {
+ $i++;
+ $name = $this->getObjectNameString($object);
+ if ($object->hasBeenLoadedFromDb()) {
+ if ($object instanceof IcingaHost) {
+ $names[$name] = Link::create(
+ $name,
+ 'director/host',
+ ['name' => $name],
+ ['data-base-target' => '_next']
+ );
+ $oldObject = IcingaHost::load($object->getObjectName(), $this->db());
+ $cfgNew = new IcingaConfig($this->db());
+ $cfgOld = new IcingaConfig($this->db());
+ $oldObject->renderToConfig($cfgOld);
+ $object->renderToConfig($cfgNew);
+ foreach (IcingaConfigDiff::getDiffs($cfgOld, $cfgNew) as $file => $diff) {
+ $names[$name . '___PRETITLE___' . $file] = Html::tag('h3', $file);
+ $names[$name . '___PREVIEW___' . $file] = $diff;
+ }
+ } elseif ($object instanceof IcingaService && $object->isObject()) {
+ $host = $object->getRelated('host');
+
+ $names[$name] = Link::create(
+ $name,
+ 'director/service/edit',
+ [
+ 'name' => $object->getObjectName(),
+ 'host' => $host->getObjectName()
+ ],
+ ['data-base-target' => '_next']
+ );
+ $oldObject = IcingaService::load([
+ 'host_id' => $host->get('id'),
+ 'object_name' => $object->getObjectName()
+ ], $this->db());
+
+ $cfgNew = new IcingaConfig($this->db());
+ $cfgOld = new IcingaConfig($this->db());
+ $oldObject->renderToConfig($cfgOld);
+ $object->renderToConfig($cfgNew);
+ foreach (IcingaConfigDiff::getDiffs($cfgOld, $cfgNew) as $file => $diff) {
+ $names[$name . '___PRETITLE___' . $file] = Html::tag('h3', $file);
+ $names[$name . '___PREVIEW___' . $file] = $diff;
+ }
+ } else {
+ $names[$name] = $name;
+ }
+ } else {
+ $names[$name] = $name;
+ }
+ if ($i === $max) {
+ break;
+ }
+ }
+ ksort($names);
+
+ foreach ($names as $name) {
+ $list->addItem($name);
+ }
+
+ if ($total > $max) {
+ $list->add(sprintf(
+ $this->translate('...and %d more'),
+ $total - $max
+ ));
+ }
+
+ return $list;
+ }
+
+ protected function listModifiedProperties($properties)
+ {
+ $list = new UnorderedList();
+ foreach ($properties as $property => $cnt) {
+ $list->addItem("${cnt}x $property");
+ }
+
+ return $list;
+ }
+
+ protected function getObjectNameString($object)
+ {
+ if ($object instanceof IcingaService) {
+ if ($object->isObject()) {
+ return $object->getRelated('host')->getObjectName()
+ . ': ' . $object->getObjectName();
+ } else {
+ return $object->getObjectName();
+ }
+ } elseif ($object instanceof IcingaHost) {
+ return $object->getObjectName();
+ } elseif ($object instanceof ExportInterface) {
+ return $object->getUniqueIdentifier();
+ } elseif ($object instanceof IcingaObject) {
+ return $object->getObjectName();
+ } else {
+ /** @var \Icinga\Module\Director\Data\Db\DbObject $object */
+ return json_encode($object->getKeyParams());
+ }
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function editAction()
+ {
+ $form = SyncRuleForm::load()
+ ->setListUrl('director/syncrules')
+ ->setDb($this->db());
+
+ if ($id = $this->params->get('id')) {
+ $form->loadObject((int) $id);
+ /** @var SyncRule $rule */
+ $rule = $form->getObject();
+ $this->tabs(new SyncRuleTabs($rule))->activate('edit');
+ $this->addTitle(sprintf(
+ $this->translate('Sync rule: %s'),
+ $rule->get('rule_name')
+ ));
+ $this->addMainActions();
+
+ if (! $rule->hasSyncProperties()) {
+ $this->addPropertyHint($rule);
+ }
+ if ($this->showNotInBranch($this->translate('Modifying Sync Rules'))) {
+ return;
+ }
+
+ } else {
+ $this->addTitle($this->translate('Add sync rule'));
+ $this->tabs(new SyncRuleTabs())->activate('add');
+ if ($this->showNotInBranch($this->translate('Creating Sync Rules'))) {
+ return;
+ }
+ }
+
+ $form->handleRequest();
+ $this->content()->add($form);
+ }
+
+ /**
+ * @throws \Icinga\Exception\MissingParameterException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function cloneAction()
+ {
+ $id = $this->params->getRequired('id');
+ $rule = SyncRule::loadWithAutoIncId((int) $id, $this->db());
+ $this->tabs()->add('show', [
+ 'url' => 'director/syncrule',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Sync rule'),
+ ])->add('clone', [
+ 'url' => 'director/syncrule/clone',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Clone'),
+ ])->activate('clone');
+ $this->addTitle('Clone: %s', $rule->get('rule_name'));
+ $this->actions()->add(
+ Link::create(
+ $this->translate('Modify'),
+ 'director/syncrule/edit',
+ ['id' => $rule->get('id')],
+ ['class' => 'icon-paste']
+ )
+ );
+ if ($this->showNotInBranch($this->translate('Cloning Sync Rules'))) {
+ return;
+ }
+
+ $form = new CloneSyncRuleForm($rule);
+ $this->content()->add($form);
+ $form->on(Form::ON_SUCCESS, function (CloneSyncRuleForm $form) {
+ $this->getResponse()->redirectAndExit($form->getSuccessUrl());
+ });
+ $form->handleRequest($this->getServerRequest());
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function propertyAction()
+ {
+ $rule = $this->requireSyncRule('rule_id');
+ $this->tabs(new SyncRuleTabs($rule))->activate('property');
+
+ $this->actions()->add(Link::create(
+ $this->translate('Add sync property rule'),
+ 'director/syncrule/addproperty',
+ ['rule_id' => $rule->get('id')],
+ ['class' => 'icon-plus']
+ ));
+ $this->addTitle($this->translate('Sync properties') . ': ' . $rule->get('rule_name'));
+
+ SyncpropertyTable::create($rule)
+ ->handleSortPriorityActions($this->getRequest(), $this->getResponse())
+ ->renderTo($this);
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function editpropertyAction()
+ {
+ $this->addpropertyAction();
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function addpropertyAction()
+ {
+ $db = $this->db();
+ $rule = $this->requireSyncRule('rule_id');
+ $ruleId = (int) $rule->get('id');
+
+ $form = SyncPropertyForm::load()->setDb($db);
+ $this->tabs(new SyncRuleTabs($rule))->activate('property');
+ $this->actions()->add(new Link(
+ $this->translate('back'),
+ 'director/syncrule/property',
+ ['rule_id' => $ruleId],
+ ['class' => 'icon-left-big']
+ ));
+
+ if ($id = $this->params->get('id')) {
+ $form->loadObject((int) $id);
+ $this->addTitle(
+ $this->translate('Sync "%s": %s'),
+ $form->getObject()->get('destination_field'),
+ $rule->get('rule_name')
+ );
+ if ($this->showNotInBranch($this->translate('Modifying Sync Rules'))) {
+ return;
+ }
+ } else {
+ $this->addTitle(
+ $this->translate('Add sync property: %s'),
+ $rule->get('rule_name')
+ );
+ if ($this->showNotInBranch($this->translate('Modifying Sync Rules'))) {
+ return;
+ }
+ }
+ $form->setRule($rule);
+ $form->setSuccessUrl('director/syncrule/property', ['rule_id' => $ruleId]);
+ $this->content()->add($form->handleRequest());
+ SyncpropertyTable::create($rule)
+ ->handleSortPriorityActions($this->getRequest(), $this->getResponse())
+ ->renderTo($this);
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function historyAction()
+ {
+ $this->setAutoRefreshInterval(30);
+ $rule = $this->requireSyncRule();
+ $this->tabs(new SyncRuleTabs($rule))->activate('history');
+ $this->addTitle($this->translate('Sync history') . ': ' . $rule->get('rule_name'));
+
+ if ($runId = $this->params->get('run_id')) {
+ $run = SyncRun::load($runId, $this->db());
+ $this->content()->add(new SyncRunDetails($run));
+ }
+ (new SyncRunTable($rule))->renderTo($this);
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function addMainActions()
+ {
+ $this->actions(new AutomationObjectActionBar(
+ $this->getRequest()
+ ));
+ $source = $this->requireSyncRule();
+
+ $this->actions()->add(Link::create(
+ $this->translate('Add to Basket'),
+ 'director/basket/add',
+ [
+ 'type' => 'SyncRule',
+ 'names' => $source->getUniqueIdentifier()
+ ],
+ [
+ 'class' => 'icon-tag',
+ 'data-base-target' => '_next',
+ ]
+ ));
+ }
+
+ /**
+ * @param string $key
+ * @return SyncRule
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function requireSyncRule($key = 'id')
+ {
+ $id = $this->params->get($key);
+ return SyncRule::loadWithAutoIncId($id, $this->db());
+ }
+}