summaryrefslogtreecommitdiffstats
path: root/library/Director/Web/Widget
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Director/Web/Widget/AbstractList.php40
-rw-r--r--library/Director/Web/Widget/ActivityLogInfo.php634
-rw-r--r--library/Director/Web/Widget/AdditionalTableActions.php158
-rw-r--r--library/Director/Web/Widget/BackgroundDaemonDetails.php131
-rw-r--r--library/Director/Web/Widget/BranchedObjectHint.php69
-rw-r--r--library/Director/Web/Widget/BranchedObjectsHint.php27
-rw-r--r--library/Director/Web/Widget/Daemon/BackgroundDaemonState.php57
-rw-r--r--library/Director/Web/Widget/DeployedConfigInfoHeader.php101
-rw-r--r--library/Director/Web/Widget/DeploymentInfo.php169
-rw-r--r--library/Director/Web/Widget/Documentation.php97
-rw-r--r--library/Director/Web/Widget/HealthCheckPluginOutput.php94
-rw-r--r--library/Director/Web/Widget/IcingaConfigDiff.php58
-rw-r--r--library/Director/Web/Widget/IcingaObjectInspection.php254
-rw-r--r--library/Director/Web/Widget/ImportSourceDetails.php83
-rw-r--r--library/Director/Web/Widget/InspectPackages.php174
-rw-r--r--library/Director/Web/Widget/JobDetails.php69
-rw-r--r--library/Director/Web/Widget/ListItem.php26
-rw-r--r--library/Director/Web/Widget/NotInBranchedHint.php23
-rw-r--r--library/Director/Web/Widget/OrderedList.php8
-rw-r--r--library/Director/Web/Widget/ShowConfigFile.php106
-rw-r--r--library/Director/Web/Widget/SyncRunDetails.php129
-rw-r--r--library/Director/Web/Widget/UnorderedList.php8
22 files changed, 2515 insertions, 0 deletions
diff --git a/library/Director/Web/Widget/AbstractList.php b/library/Director/Web/Widget/AbstractList.php
new file mode 100644
index 0000000..ad1b9e3
--- /dev/null
+++ b/library/Director/Web/Widget/AbstractList.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+
+class AbstractList extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ /**
+ * AbstractList constructor.
+ * @param array $items
+ * @param null $attributes
+ */
+ public function __construct(array $items = [], $attributes = null)
+ {
+ foreach ($items as $item) {
+ $this->addItem($item);
+ }
+
+ if ($attributes !== null) {
+ $this->addAttributes($attributes);
+ }
+ }
+
+ /**
+ * @param Html|array|string $content
+ * @param Attributes|array $attributes
+ *
+ * @return $this
+ */
+ public function addItem($content, $attributes = null)
+ {
+ return $this->add(HtmlElement::create('li', $attributes, $content));
+ }
+}
diff --git a/library/Director/Web/Widget/ActivityLogInfo.php b/library/Director/Web/Widget/ActivityLogInfo.php
new file mode 100644
index 0000000..8454b26
--- /dev/null
+++ b/library/Director/Web/Widget/ActivityLogInfo.php
@@ -0,0 +1,634 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Json\JsonString;
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Forms\RestoreObjectForm;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ActivityLogInfo extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $defaultTab;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string */
+ protected $typeName;
+
+ /** @var string */
+ protected $name;
+
+ protected $entry;
+
+ protected $oldProperties;
+
+ protected $newProperties;
+
+ protected $oldObject;
+
+ /** @var Tabs */
+ protected $tabs;
+
+ /** @var int */
+ protected $id;
+
+ public function __construct(Db $db, $type = null, $name = null)
+ {
+ $this->db = $db;
+ if ($type !== null) {
+ $this->setType($type);
+ }
+ $this->name = $name;
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+ $this->typeName = $this->translate(
+ ucfirst(preg_replace('/^icinga_/', '', $type)) // really?
+ );
+
+ return $this;
+ }
+
+ /**
+ * @param Url $url
+ * @return HtmlElement
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function getPagination(Url $url)
+ {
+ /** @var Url $url */
+ $url = $url->without('checksum')->without('show');
+ $div = Html::tag('div', [
+ 'class' => 'pagination-control',
+ 'style' => 'float: right; width: 5em'
+ ]);
+
+ $ul = Html::tag('ul', ['class' => 'nav tab-nav']);
+ $li = Html::tag('li', ['class' => 'nav-item']);
+ $ul->add($li);
+ $neighbors = $this->getNeighbors();
+ $iconLeft = new Icon('angle-double-left');
+ $iconRight = new Icon('angle-double-right');
+ if ($neighbors->prev) {
+ $li->add(new Link($iconLeft, $url->with('id', $neighbors->prev)));
+ } else {
+ $li->add(Html::tag('span', ['class' => 'disabled'], $iconLeft));
+ }
+
+ $li = Html::tag('li', ['class' => 'nav-item']);
+ $ul->add($li);
+ if ($neighbors->next) {
+ $li->add(new Link($iconRight, $url->with('id', $neighbors->next)));
+ } else {
+ $li->add(Html::tag('span', ['class' => 'disabled'], $iconRight));
+ }
+
+ return $div->add($ul);
+ }
+
+ /**
+ * @param $tabName
+ * @return $this
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function showTab($tabName)
+ {
+ if ($tabName === null) {
+ $tabName = $this->defaultTab;
+ }
+
+ $this->getTabs()->activate($tabName);
+ $this->add($this->getInfoTable());
+ if ($tabName === 'old') {
+ // $title = sprintf('%s former config', $this->entry->object_name);
+ $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->emptyConfig());
+ } elseif ($tabName === 'new') {
+ // $title = sprintf('%s new config', $this->entry->object_name);
+ $diffs = IcingaConfigDiff::getDiffs($this->emptyConfig(), $this->newConfig());
+ } else {
+ $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->newConfig());
+ }
+
+ $this->addDiffs($diffs);
+
+ return $this;
+ }
+
+ protected function emptyConfig()
+ {
+ return new IcingaConfig($this->db);
+ }
+
+ /**
+ * @param $diffs
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function addDiffs($diffs)
+ {
+ foreach ($diffs as $file => $diff) {
+ $this->add(Html::tag('h3', null, $file))->add($diff);
+ }
+ }
+
+ /**
+ * @return RestoreObjectForm
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function getRestoreForm()
+ {
+ return RestoreObjectForm::load()
+ ->setDb($this->db)
+ ->setObject($this->oldObject())
+ ->handleRequest();
+ }
+
+ public function setChecksum($checksum)
+ {
+ if ($checksum !== null) {
+ $this->entry = $this->db->fetchActivityLogEntry($checksum);
+ $this->id = (int) $this->entry->id;
+ }
+
+ return $this;
+ }
+
+ public function setId($id)
+ {
+ if ($id !== null) {
+ $this->entry = $this->db->fetchActivityLogEntryById($id);
+ $this->id = (int) $id;
+ }
+
+ return $this;
+ }
+
+ public function getNeighbors()
+ {
+ return $this->db->getActivitylogNeighbors(
+ $this->id,
+ $this->type,
+ $this->name
+ );
+ }
+
+ public function getCurrentObject()
+ {
+ return IcingaObject::loadByType(
+ $this->type,
+ $this->name,
+ $this->db
+ );
+ }
+
+ /**
+ * @return bool
+ * @deprecated No longer used?
+ */
+ public function objectStillExists()
+ {
+ return IcingaObject::existsByType(
+ $this->type,
+ $this->objectKey(),
+ $this->db
+ );
+ }
+
+ protected function oldProperties()
+ {
+ if ($this->oldProperties === null) {
+ if (property_exists($this->entry, 'old_properties')) {
+ $this->oldProperties = JsonString::decodeOptional($this->entry->old_properties);
+ }
+ if ($this->oldProperties === null) {
+ $this->oldProperties = new \stdClass;
+ }
+ }
+
+ return $this->oldProperties;
+ }
+
+ protected function newProperties()
+ {
+ if ($this->newProperties === null) {
+ if (property_exists($this->entry, 'new_properties')) {
+ $this->newProperties = JsonString::decodeOptional($this->entry->new_properties);
+ }
+ if ($this->newProperties === null) {
+ $this->newProperties = new \stdClass;
+ }
+ }
+
+ return $this->newProperties;
+ }
+
+ protected function getEntryProperty($key)
+ {
+ $entry = $this->entry;
+
+ if (property_exists($entry, $key)) {
+ return $entry->{$key};
+ } elseif (property_exists($this->newProperties(), $key)) {
+ return $this->newProperties->{$key};
+ } elseif (property_exists($this->oldProperties(), $key)) {
+ return $this->oldProperties->{$key};
+ } else {
+ return null;
+ }
+ }
+
+ protected function objectLinkParams()
+ {
+ $entry = $this->entry;
+
+ $params = ['name' => $entry->object_name];
+
+ if ($entry->object_type === 'icinga_service') {
+ if (($set = $this->getEntryProperty('service_set')) !== null) {
+ $params['set'] = $set;
+ return $params;
+ } elseif (($host = $this->getEntryProperty('host')) !== null) {
+ $params['host'] = $host;
+ return $params;
+ } else {
+ return $params;
+ }
+ } elseif ($entry->object_type === 'icinga_service_set') {
+ return $params;
+ } else {
+ return $params;
+ }
+ }
+
+ protected function getActionExtraHtml()
+ {
+ $entry = $this->entry;
+
+ $info = '';
+ $host = null;
+
+ if ($entry->object_type === 'icinga_service') {
+ if (($set = $this->getEntryProperty('service_set')) !== null) {
+ $info = Html::sprintf(
+ '%s "%s"',
+ $this->translate('on service set'),
+ Link::create(
+ $set,
+ 'director/serviceset',
+ ['name' => $set],
+ ['data-base-target' => '_next']
+ )
+ );
+ } else {
+ $host = $this->getEntryProperty('host');
+ }
+ } elseif ($entry->object_type === 'icinga_service_set') {
+ $host = $this->getEntryProperty('host');
+ }
+
+ if ($host !== null) {
+ $info = Html::sprintf(
+ '%s "%s"',
+ $this->translate('on host'),
+ Link::create(
+ $host,
+ 'director/host',
+ ['name' => $host],
+ ['data-base-target' => '_next']
+ )
+ );
+ }
+
+ return $info;
+ }
+
+ /**
+ * @return array
+ * @deprecated No longer used?
+ */
+ protected function objectKey()
+ {
+ $entry = $this->entry;
+ if ($entry->object_type === 'icinga_service' || $entry->object_type === 'icinga_service_set') {
+ // TODO: this is not correct. Activity needs to get (multi) key support
+ return ['name' => $entry->object_name];
+ }
+
+ return $entry->object_name;
+ }
+
+ /**
+ * @param Url|null $url
+ * @return Tabs
+ */
+ public function getTabs(Url $url = null)
+ {
+ if ($this->tabs === null) {
+ $this->tabs = $this->createTabs($url);
+ }
+
+ return $this->tabs;
+ }
+
+ /**
+ * @param Url $url
+ * @return Tabs
+ */
+ public function createTabs(Url $url)
+ {
+ $entry = $this->entry;
+ $tabs = new Tabs();
+ if ($entry->action_name === DirectorActivityLog::ACTION_MODIFY) {
+ $tabs->add('diff', [
+ 'label' => $this->translate('Diff'),
+ 'url' => $url->without('show')->with('id', $entry->id)
+ ]);
+
+ $this->defaultTab = 'diff';
+ }
+
+ if (in_array($entry->action_name, [
+ DirectorActivityLog::ACTION_CREATE,
+ DirectorActivityLog::ACTION_MODIFY,
+ ])) {
+ $tabs->add('new', [
+ 'label' => $this->translate('New object'),
+ 'url' => $url->with(['id' => $entry->id, 'show' => 'new'])
+ ]);
+
+ if ($this->defaultTab === null) {
+ $this->defaultTab = 'new';
+ }
+ }
+
+ if (in_array($entry->action_name, [
+ DirectorActivityLog::ACTION_DELETE,
+ DirectorActivityLog::ACTION_MODIFY,
+ ])) {
+ $tabs->add('old', [
+ 'label' => $this->translate('Former object'),
+ 'url' => $url->with(['id' => $entry->id, 'show' => 'old'])
+ ]);
+
+ if ($this->defaultTab === null) {
+ $this->defaultTab = 'old';
+ }
+ }
+
+ return $tabs;
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function oldObject()
+ {
+ if ($this->oldObject === null) {
+ $this->oldObject = $this->createObject(
+ $this->entry->object_type,
+ $this->entry->old_properties
+ );
+ }
+
+ return $this->oldObject;
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function newObject()
+ {
+ return $this->createObject(
+ $this->entry->object_type,
+ $this->entry->new_properties
+ );
+ }
+
+ protected function objectToConfig(IcingaObject $object)
+ {
+ if ($object instanceof IcingaService) {
+ return $this->previewService($object);
+ } else {
+ return $object->toSingleIcingaConfig();
+ }
+ }
+
+ protected function previewService(IcingaService $service)
+ {
+ if (($set = $service->get('service_set')) !== null) {
+ // simulate rendering of service in set
+ $set = IcingaServiceSet::load($set, $this->db);
+
+ $service->set('service_set_id', null);
+ if (($assign = $set->get('assign_filter')) !== null) {
+ $service->set('object_type', 'apply');
+ $service->set('assign_filter', $assign);
+ }
+ }
+
+ return $service->toSingleIcingaConfig();
+ }
+
+ /**
+ * @return IcingaConfig
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function newConfig()
+ {
+ return $this->objectToConfig($this->newObject());
+ }
+
+ /**
+ * @return IcingaConfig
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function oldConfig()
+ {
+ return $this->objectToConfig($this->oldObject());
+ }
+
+ protected function getLinkToObject()
+ {
+ // TODO: This logic is redundant and should be centralized
+ $entry = $this->entry;
+ $name = $entry->object_name;
+ $controller = preg_replace('/^icinga_/', '', $entry->object_type);
+
+ if ($controller === 'service_set') {
+ $controller = 'serviceset';
+ } elseif ($controller === 'scheduled_downtime') {
+ $controller = 'scheduled-downtime';
+ }
+
+ return Link::create(
+ $name,
+ 'director/' . $controller,
+ $this->objectLinkParams(),
+ ['data-base-target' => '_next']
+ );
+ }
+
+ /**
+ * @return NameValueTable
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function getInfoTable()
+ {
+ $entry = $this->entry;
+ $table = new NameValueTable();
+ $table->addNameValuePairs([
+ $this->translate('Author') => $entry->author,
+ $this->translate('Date') => DateFormatter::formatDateTime(
+ $entry->change_time_ts
+ ),
+
+ ]);
+ if (null === $this->name) {
+ $table->addNameValueRow(
+ $this->translate('Action'),
+ Html::sprintf(
+ '%s %s "%s" %s',
+ $entry->action_name,
+ $entry->object_type,
+ $this->getLinkToObject(),
+ $this->getActionExtraHtml()
+ )
+ );
+ } else {
+ $table->addNameValueRow(
+ $this->translate('Action'),
+ $entry->action_name
+ );
+ }
+
+ if ($comment = $this->getOptionalRangeComment()) {
+ $table->addNameValueRow(
+ $this->translate('Remark'),
+ $comment
+ );
+ }
+
+ if ($this->hasBeenEnabled()) {
+ $table->addNameValueRow(
+ $this->translate('Rendering'),
+ $this->translate('This object has been enabled')
+ );
+ } elseif ($this->hasBeenDisabled()) {
+ $table->addNameValueRow(
+ $this->translate('Rendering'),
+ $this->translate('This object has been disabled')
+ );
+ }
+
+ $table->addNameValueRow(
+ $this->translate('Checksum'),
+ $entry->checksum
+ );
+ if ($this->entry->old_properties) {
+ $table->addNameValueRow(
+ $this->translate('Actions'),
+ $this->getRestoreForm()
+ );
+ }
+
+ return $table;
+ }
+
+ public function hasBeenEnabled()
+ {
+ return false;
+ }
+
+ public function hasBeenDisabled()
+ {
+ return false;
+ }
+
+ /**
+ * @return string
+ * @throws ProgrammingError
+ */
+ public function getTitle()
+ {
+ switch ($this->entry->action_name) {
+ case DirectorActivityLog::ACTION_CREATE:
+ $msg = $this->translate('%s "%s" has been created');
+ break;
+ case DirectorActivityLog::ACTION_DELETE:
+ $msg = $this->translate('%s "%s" has been deleted');
+ break;
+ case DirectorActivityLog::ACTION_MODIFY:
+ $msg = $this->translate('%s "%s" has been modified');
+ break;
+ default:
+ throw new ProgrammingError(
+ 'Unable to deal with "%s" activity',
+ $this->entry->action_name
+ );
+ }
+
+ return sprintf($msg, $this->typeName, $this->entry->object_name);
+ }
+
+ protected function getOptionalRangeComment()
+ {
+ if ($this->id) {
+ $db = $this->db->getDbAdapter();
+ return $db->fetchOne(
+ $db->select()
+ ->from('director_activity_log_remark', 'remark')
+ ->where('first_related_activity <= ?', $this->id)
+ ->where('last_related_activity >= ?', $this->id)
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param $type
+ * @param $props
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function createObject($type, $props)
+ {
+ $props = json_decode($props);
+ $newProps = ['object_name' => $props->object_name];
+ if (property_exists($props, 'object_type')) {
+ $newProps['object_type'] = $props->object_type;
+ }
+
+ return IcingaObject::createByType(
+ $type,
+ $newProps,
+ $this->db
+ )->setProperties((array) $props);
+ }
+}
diff --git a/library/Director/Web/Widget/AdditionalTableActions.php b/library/Director/Web/Widget/AdditionalTableActions.php
new file mode 100644
index 0000000..978f399
--- /dev/null
+++ b/library/Director/Web/Widget/AdditionalTableActions.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Web\Table\FilterableByUsage;
+
+class AdditionalTableActions
+{
+ use TranslationHelper;
+
+ /** @var Auth */
+ protected $auth;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var ZfQueryBasedTable */
+ protected $table;
+
+ public function __construct(Auth $auth, Url $url, ZfQueryBasedTable $table)
+ {
+ $this->auth = $auth;
+ $this->url = $url;
+ $this->table = $table;
+ }
+
+ public function appendTo(HtmlDocument $parent)
+ {
+ $links = [];
+ if ($this->hasPermission('director/admin')) {
+ $links[] = $this->createDownloadJsonLink();
+ }
+ if ($this->hasPermission('director/showsql')) {
+ $links[] = $this->createShowSqlToggle();
+ }
+
+ if ($this->table instanceof FilterableByUsage) {
+ $parent->add($this->showUsageFilter($this->table));
+ }
+
+ if (! empty($links)) {
+ $parent->add($this->moreOptions($links));
+ }
+
+ return $this;
+ }
+
+ protected function createDownloadJsonLink()
+ {
+ return Link::create(
+ $this->translate('Download as JSON'),
+ $this->url->with('format', 'json'),
+ null,
+ ['target' => '_blank']
+ );
+ }
+
+ protected function createShowSqlToggle()
+ {
+ if ($this->url->getParam('format') === 'sql') {
+ $link = Link::create(
+ $this->translate('Hide SQL'),
+ $this->url->without('format')
+ );
+ } else {
+ $link = Link::create(
+ $this->translate('Show SQL'),
+ $this->url->with('format', 'sql')
+ );
+ }
+
+ return $link;
+ }
+
+ protected function showUsageFilter(FilterableByUsage $table)
+ {
+ $active = $this->url->getParam('usage', 'all');
+ $links = [
+ Link::create($this->translate('all'), $this->url->without('usage')),
+ Link::create($this->translate('used'), $this->url->with('usage', 'used')),
+ Link::create($this->translate('unused'), $this->url->with('usage', 'unused')),
+ ];
+
+ if ($active === 'used') {
+ $table->showOnlyUsed();
+ } elseif ($active === 'unused') {
+ $table->showOnlyUnUsed();
+ }
+
+ $options = $this->ul(
+ $this->li([
+ Link::create(
+ sprintf($this->translate('Usage (%s)'), $active),
+ '#',
+ null,
+ [
+ 'class' => 'icon-sitemap'
+ ]
+ ),
+ $subUl = Html::tag('ul')
+ ]),
+ ['class' => 'nav']
+ );
+
+ foreach ($links as $link) {
+ $subUl->add($this->li($link));
+ }
+
+ return $options;
+ }
+
+ protected function moreOptions($links)
+ {
+ $options = $this->ul(
+ $this->li([
+ // TODO: extend link for dropdown-toggle from Web 2, doesn't
+ // seem to work: [..], null, ['class' => 'dropdown-toggle']
+ Link::create(Icon::create('down-open'), '#'),
+ $subUl = Html::tag('ul')
+ ]),
+ ['class' => 'nav']
+ );
+
+ foreach ($links as $link) {
+ $subUl->add($this->li($link));
+ }
+
+ return $options;
+ }
+
+ protected function ulLi($content)
+ {
+ return $this->ul($this->li($content));
+ }
+
+ protected function ul($content, $attributes = null)
+ {
+ return Html::tag('ul', $attributes, $content);
+ }
+
+ protected function li($content)
+ {
+ return Html::tag('li', null, $content);
+ }
+
+ protected function hasPermission($permission)
+ {
+ return $this->auth->hasPermission($permission);
+ }
+}
diff --git a/library/Director/Web/Widget/BackgroundDaemonDetails.php b/library/Director/Web/Widget/BackgroundDaemonDetails.php
new file mode 100644
index 0000000..b4c33dd
--- /dev/null
+++ b/library/Director/Web/Widget/BackgroundDaemonDetails.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Daemon\RunningDaemonInfo;
+use Icinga\Util\Format;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\Table;
+
+class BackgroundDaemonDetails extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'div';
+
+ /** @var RunningDaemonInfo */
+ protected $info;
+
+ /** @var \stdClass TODO: get rid of this */
+ protected $daemon;
+
+ public function __construct(RunningDaemonInfo $info, $daemon)
+ {
+ $this->info = $info;
+ $this->daemon = $daemon;
+ }
+
+ protected function assemble()
+ {
+ $info = $this->info;
+ if ($info->hasBeenStopped()) {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate(
+ 'Daemon has been stopped %s, was running with PID %s as %s@%s'
+ ),
+ // $info->getHexUuid(),
+ $this->timeAgo($info->getTimestampStopped() / 1000),
+ Html::tag('strong', (string) $info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn())
+ )));
+ } elseif ($info->isOutdated()) {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate(
+ 'Daemon keep-alive is outdated, was last seen running with PID %s as %s@%s %s'
+ ),
+ // $info->getHexUuid(),
+ Html::tag('strong', (string) $info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn()),
+ $this->timeAgo($info->getLastUpdate() / 1000)
+ )));
+ } else {
+ $this->add(Hint::ok(Html::sprintf(
+ $this->translate(
+ 'Daemon is running with PID %s as %s@%s, last refresh happened %s'
+ ),
+ // $info->getHexUuid(),
+ Html::tag('strong', (string)$info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn()),
+ $this->timeAgo($info->getLastUpdate() / 1000)
+ )));
+ $details = new NameValueTable();
+ $details->addNameValuePairs([
+ $this->translate('Startup Time') => DateFormatter::formatDateTime($info->getTimestampStarted() / 1000),
+ $this->translate('PID') => $info->getPid(),
+ $this->translate('Username') => $info->getUsername(),
+ $this->translate('FQDN') => $info->getFqdn(),
+ $this->translate('Running with systemd') => $info->isRunningWithSystemd()
+ ? $this->translate('yes')
+ : $this->translate('no'),
+ $this->translate('Binary') => $info->getBinaryPath()
+ . ($info->binaryRealpathDiffers() ? ' -> ' . $info->getBinaryRealpath() : ''),
+ $this->translate('PHP Binary') => $info->getPhpBinaryPath()
+ . ($info->phpBinaryRealpathDiffers() ? ' -> ' . $info->getPhpBinaryRealpath() : ''),
+ $this->translate('PHP Version') => $info->getPhpVersion(),
+ $this->translate('PHP Integer') => $info->has64bitIntegers()
+ ? '64bit'
+ : Html::sprintf(
+ '%sbit (%s)',
+ $info->getPhpIntegerSize() * 8,
+ Html::tag('span', ['class' => 'error'], $this->translate('unsupported'))
+ ),
+ ]);
+ $this->add($details);
+
+ $this->add(Html::tag('h2', $this->translate('Process List')));
+ if (\is_string($this->daemon->process_info)) {
+ // from DB:
+ $processes = \json_decode($this->daemon->process_info);
+ } else {
+ // via RPC:
+ $processes = $this->daemon->process_info;
+ }
+ $table = new Table();
+ $table->add(Html::tag('thead', Html::tag('tr', Html::wrapEach([
+ 'PID',
+ 'Command',
+ 'Memory'
+ ], 'th'))));
+ $table->setAttribute('class', 'common-table');
+ foreach ($processes as $pid => $process) {
+ $table->add($table::row([
+ [
+ Icon::create($process->running ? 'ok' : 'warning-empty'),
+ ' ',
+ $pid
+ ],
+ Html::tag('pre', $process->command),
+ $process->memory === false ? 'n/a' : Format::bytes($process->memory->rss)
+ ]));
+ }
+ $this->add($table);
+ }
+ }
+
+ protected function timeAgo($time)
+ {
+ return Html::tag('span', [
+ 'class' => 'time-ago',
+ 'title' => DateFormatter::formatDateTime($time)
+ ], DateFormatter::timeAgo($time));
+ }
+}
diff --git a/library/Director/Web/Widget/BranchedObjectHint.php b/library/Director/Web/Widget/BranchedObjectHint.php
new file mode 100644
index 0000000..ec16094
--- /dev/null
+++ b/library/Director/Web/Widget/BranchedObjectHint.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchedObject;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+
+class BranchedObjectHint extends HtmlDocument
+{
+ use TranslationHelper;
+
+ public function __construct(Branch $branch, Auth $auth, BranchedObject $object = null)
+ {
+ if (! $branch->isBranch()) {
+ return;
+ }
+ $hook = Branch::requireHook();
+
+ $name = $branch->getName();
+ if (substr($name, 0, 1) === '/') {
+ $label = $this->translate('this configuration branch');
+ } else {
+ $label = $name;
+ }
+ $link = $hook->linkToBranch($branch, $auth, $label);
+ if ($object === null) {
+ $this->add(Hint::info(Html::sprintf($this->translate(
+ 'This object will be created in %s. It will not be part of any deployment'
+ . ' unless being merged'
+ ), $link)));
+ return;
+ }
+
+ if (! $object->hasBeenTouchedByBranch()) {
+ $this->add(Hint::info(Html::sprintf($this->translate(
+ 'Your changes will be stored in %s. The\'ll not be part of any deployment'
+ . ' unless being merged'
+ ), $link)));
+ return;
+ }
+
+ if ($object->hasBeenDeletedByBranch()) {
+ throw new NotFoundError('No such object available');
+ // Alternative, requires hiding other actions:
+ // $this->add(Hint::info(Html::sprintf(
+ // $this->translate('This object has been deleted in %s'),
+ // $link
+ // )));
+ } elseif ($object->hasBeenCreatedByBranch()) {
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('This object has been created in %s'),
+ $link
+ )));
+ } else {
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('This object has modifications visible only in %s'),
+ // TODO: Also link to object modifications
+ // $hook->linkToBranchedObject($this->translate('modifications'), $branch, $object, $auth),
+ $link
+ )));
+ }
+ }
+}
diff --git a/library/Director/Web/Widget/BranchedObjectsHint.php b/library/Director/Web/Widget/BranchedObjectsHint.php
new file mode 100644
index 0000000..d689178
--- /dev/null
+++ b/library/Director/Web/Widget/BranchedObjectsHint.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+
+class BranchedObjectsHint extends HtmlDocument
+{
+ use TranslationHelper;
+
+ public function __construct(Branch $branch, Auth $auth)
+ {
+ if (! $branch->isBranch()) {
+ return;
+ }
+ $hook = Branch::requireHook();
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('Showing a branched view, with potential changes being visible only in this %s'),
+ $hook->linkToBranch($branch, $auth, $this->translate('configuration branch'))
+ )));
+ }
+}
diff --git a/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php
new file mode 100644
index 0000000..03e76b2
--- /dev/null
+++ b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget\Daemon;
+
+use Icinga\Module\Director\Daemon\RunningDaemonInfo;
+use Icinga\Module\Director\Db;
+
+class BackgroundDaemonState
+{
+ protected $db;
+
+ /** @var RunningDaemonInfo[] */
+ protected $instances;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ public function isRunning()
+ {
+ foreach ($this->getInstances() as $instance) {
+ if ($instance->isRunning()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getInstances()
+ {
+ if ($this->instances === null) {
+ $this->instances = $this->fetchInfo();
+ }
+
+ return $this->instances;
+ }
+
+ /**
+ * @return RunningDaemonInfo[]
+ */
+ protected function fetchInfo()
+ {
+ $db = $this->db->getDbAdapter();
+ $daemons = $db->fetchAll(
+ $db->select()->from('director_daemon_info')->order('fqdn')->order('username')->order('pid')
+ );
+
+ $result = [];
+ foreach ($daemons as $info) {
+ $result[] = new RunningDaemonInfo($info);
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/Web/Widget/DeployedConfigInfoHeader.php b/library/Director/Web/Widget/DeployedConfigInfoHeader.php
new file mode 100644
index 0000000..0e841f3
--- /dev/null
+++ b/library/Director/Web/Widget/DeployedConfigInfoHeader.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Core\DeploymentApiInterface;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Forms\DeployConfigForm;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+
+class DeployedConfigInfoHeader extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ /** @var int */
+ protected $deploymentId;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var DeploymentApiInterface */
+ protected $api;
+
+ /** @var Branch */
+ protected $branch;
+
+ public function __construct(
+ IcingaConfig $config,
+ Db $db,
+ DeploymentApiInterface $api,
+ Branch $branch,
+ $deploymentId = null
+ ) {
+ $this->config = $config;
+ $this->db = $db;
+ $this->api = $api;
+ $this->branch = $branch;
+ if ($deploymentId) {
+ $this->deploymentId = (int) $deploymentId;
+ }
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Zend_Form_Exception
+ */
+ protected function assemble()
+ {
+ $config = $this->config;
+ if ($this->branch->isBranch()) {
+ $deployForm = null;
+ } else {
+ $deployForm = DeployConfigForm::load()
+ ->setDb($this->db)
+ ->setApi($this->api)
+ ->setChecksum($config->getHexChecksum())
+ ->setDeploymentId($this->deploymentId)
+ ->setAttrib('class', 'inline')
+ ->handleRequest();
+ }
+
+ $links = new NameValueTable();
+ $links->addNameValueRow(
+ $this->translate('Actions'),
+ [
+ $deployForm,
+ Html::tag('br'),
+ Link::create(
+ $this->translate('Last related activity'),
+ 'director/config/activity',
+ ['checksum' => $config->getLastActivityHexChecksum()],
+ ['class' => 'icon-clock', 'data-base-target' => '_next']
+ ),
+ Html::tag('br'),
+ Link::create(
+ $this->translate('Diff with other config'),
+ 'director/config/diff',
+ ['left' => $config->getHexChecksum()],
+ ['class' => 'icon-flapping', 'data-base-target' => '_self']
+ )
+ ]
+ )->addNameValueRow(
+ $this->translate('Statistics'),
+ sprintf(
+ $this->translate('%d files rendered in %0.2fs'),
+ count($config->getFiles()),
+ $config->getDuration() / 1000
+ )
+ );
+
+ $this->add($links);
+ }
+}
diff --git a/library/Director/Web/Widget/DeploymentInfo.php b/library/Director/Web/Widget/DeploymentInfo.php
new file mode 100644
index 0000000..110200f
--- /dev/null
+++ b/library/Director/Web/Widget/DeploymentInfo.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\HtmlDocument;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use Icinga\Module\Director\StartupLogRenderer;
+use Icinga\Util\Format;
+use Icinga\Web\Request;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class DeploymentInfo extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var DirectorDeploymentLog */
+ protected $deployment;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ /**
+ * DeploymentInfo constructor.
+ * @param DirectorDeploymentLog $deployment
+ */
+ public function __construct(DirectorDeploymentLog $deployment)
+ {
+ $this->deployment = $deployment;
+ if ($deployment->get('config_checksum') !== null) {
+ $this->config = IcingaConfig::load(
+ $deployment->get('config_checksum'),
+ $deployment->getConnection()
+ );
+ }
+ }
+
+ /**
+ * @param Auth $auth
+ * @param Request $request
+ * @return Tabs
+ */
+ public function getTabs(Auth $auth, Request $request)
+ {
+ $dep = $this->deployment;
+ $tabs = new Tabs();
+ $tabs->add('deployment', array(
+ 'label' => $this->translate('Deployment'),
+ 'url' => $request->getUrl()
+ ))->activate('deployment');
+
+ if ($dep->config_checksum !== null && $auth->hasPermission('director/showconfig')) {
+ $tabs->add('config', array(
+ 'label' => $this->translate('Config'),
+ 'url' => 'director/config/files',
+ 'urlParams' => array(
+ 'checksum' => $this->config->getHexChecksum(),
+ 'deployment_id' => $dep->id
+ )
+ ));
+ }
+
+ return $tabs;
+ }
+
+ protected function createInfoTable()
+ {
+ $dep = $this->deployment;
+ $table = new NameValueTable();
+ $table->addNameValuePairs([
+ $this->translate('Deployment time') => $dep->start_time,
+ $this->translate('Sent to') => $dep->peer_identity,
+ ]);
+ if ($this->config !== null) {
+ $table->addNameValuePairs([
+ $this->translate('Configuration') => $this->getConfigDetails(),
+ $this->translate('Duration') => $this->getDurationInfo(),
+ ]);
+ }
+ $table->addNameValuePairs([
+ $this->translate('Stage name') => $dep->stage_name,
+ $this->translate('Startup') => $this->getStartupInfo()
+ ]);
+
+ return $table;
+ }
+
+ protected function getDurationInfo()
+ {
+ return sprintf(
+ $this->translate('Rendered in %0.2fs, deployed in %0.2fs'),
+ $this->config->getDuration() / 1000,
+ $this->deployment->duration_dump / 1000
+ );
+ }
+
+ protected function getConfigDetails()
+ {
+ $cfg = $this->config;
+ $dep = $this->deployment;
+
+ return [
+ Link::create(
+ sprintf($this->translate('%d files'), $cfg->getFileCount()),
+ 'director/config/files',
+ [
+ 'checksum' => $cfg->getHexChecksum(),
+ 'deployment_id' => $dep->id
+ ]
+ ),
+ ', ',
+ sprintf(
+ $this->translate('%d objects, %d templates, %d apply rules'),
+ $cfg->getObjectCount(),
+ $cfg->getTemplateCount(),
+ $cfg->getApplyCount()
+ ),
+ ', ',
+ Format::bytes($cfg->getSize())
+ ];
+ }
+
+ protected function getStartupInfo()
+ {
+ $dep = $this->deployment;
+ if ($dep->startup_succeeded === null) {
+ if ($dep->stage_collected === null) {
+ return [$this->translate('Unknown, still waiting for config check outcome'), new Icon('spinner')];
+ } else {
+ return [$this->translate('Unknown, failed to collect related information'), new Icon('help')];
+ }
+ } elseif ($dep->startup_succeeded === 'y') {
+ return $this->colored('green', [$this->translate('Succeeded'), new Icon('ok')]);
+ } else {
+ return $this->colored('red', [$this->translate('Failed'), new Icon('cancel')]);
+ }
+ }
+
+ protected function colored($color, array $content)
+ {
+ return Html::tag('div', ['style' => "color: $color;"], $content)->setSeparator(' ');
+ }
+
+ public function render()
+ {
+ $this->add($this->createInfoTable());
+ if ($this->deployment->get('startup_succeeded') !== null) {
+ $this->addStartupLog();
+ }
+
+ return parent::render();
+ }
+
+ protected function addStartupLog()
+ {
+ $this->add(Html::tag('h2', null, $this->translate('Startup Log')));
+ $this->add(
+ Html::tag('pre', [
+ 'class' => 'logfile'
+ ], new StartupLogRenderer($this->deployment))
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/Documentation.php b/library/Director/Web/Widget/Documentation.php
new file mode 100644
index 0000000..8665e30
--- /dev/null
+++ b/library/Director/Web/Widget/Documentation.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use ipl\Html\Html;
+
+class Documentation
+{
+ use TranslationHelper;
+
+ /** @var ApplicationBootstrap */
+ protected $app;
+
+ /** @var Auth */
+ protected $auth;
+
+ public function __construct(ApplicationBootstrap $app, Auth $auth)
+ {
+ $this->app = $app;
+ $this->auth = $auth;
+ }
+
+ public static function link($label, $module, $chapter, $title = null)
+ {
+ $doc = new static(Icinga::app(), Auth::getInstance());
+ return $doc->getModuleLink($label, $module, $chapter, $title);
+ }
+
+ public function getModuleLink($label, $module, $chapter, $title = null)
+ {
+ if ($title !== null) {
+ $title = sprintf(
+ $this->translate('Click to read our documentation: %s'),
+ $title
+ );
+ }
+ $linkToGitHub = false;
+ $baseParams = [
+ 'class' => 'icon-book',
+ 'title' => $title,
+ ];
+ if ($this->hasAccessToDocumentationModule()) {
+ return Link::create(
+ $label,
+ $this->getDirectorDocumentationUrl($chapter),
+ null,
+ ['data-base-target' => '_next'] + $baseParams
+ );
+ }
+
+ $baseParams['target'] = '_blank';
+ if ($linkToGitHub) {
+ return Html::tag('a', [
+ 'href' => $this->githubDocumentationUrl($module, $chapter),
+ ] + $baseParams, $label);
+ }
+
+ return Html::tag('a', [
+ 'href' => $this->icingaDocumentationUrl($module, $chapter),
+ ] + $baseParams, $label);
+ }
+
+ protected function getDirectorDocumentationUrl($chapter)
+ {
+ return 'doc/module/director/chapter/'
+ . \preg_replace('/^\d+-/', '', \rawurlencode($chapter));
+ }
+
+ protected function githubDocumentationUrl($module, $chapter)
+ {
+ return sprintf(
+ "https://github.com/Icinga/icingaweb2-module-%s/blob/master/doc/%s.md",
+ \rawurlencode($module),
+ \rawurlencode($chapter)
+ );
+ }
+
+ protected function icingaDocumentationUrl($module, $chapter)
+ {
+ return sprintf(
+ 'https://icinga.com/docs/%s/latest/doc/%s/',
+ \rawurlencode($module),
+ \rawurlencode($chapter)
+ );
+ }
+
+ protected function hasAccessToDocumentationModule()
+ {
+ return $this->app->getModuleManager()->hasLoaded('doc')
+ && $this->auth->hasPermission('module/doc');
+ }
+}
diff --git a/library/Director/Web/Widget/HealthCheckPluginOutput.php b/library/Director/Web/Widget/HealthCheckPluginOutput.php
new file mode 100644
index 0000000..83ac102
--- /dev/null
+++ b/library/Director/Web/Widget/HealthCheckPluginOutput.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\CheckPlugin\PluginState;
+use Icinga\Module\Director\Health;
+
+class HealthCheckPluginOutput extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var Health */
+ protected $health;
+
+ /** @var PluginState */
+ protected $state;
+
+ public function __construct(Health $health)
+ {
+ $this->state = new PluginState('OK');
+ $this->health = $health;
+ $this->process();
+ }
+
+ protected function process()
+ {
+ $checks = $this->health->getAllChecks();
+
+ foreach ($checks as $check) {
+ $this->add([
+ $title = Html::tag('h1', $check->getName()),
+ $ul = Html::tag('ul', ['class' => 'health-check-result'])
+ ]);
+
+ $problems = $check->getProblemSummary();
+ if (! empty($problems)) {
+ $badges = Html::tag('span', ['class' => 'title-badges']);
+ foreach ($problems as $state => $count) {
+ $badges->add(Html::tag('span', [
+ 'class' => ['badge', 'state-' . strtolower($state)],
+ 'title' => sprintf(
+ $this->translate('%s: %d'),
+ $this->translate($state),
+ $count
+ ),
+ ], $count));
+ }
+ $title->add($badges);
+ }
+
+ foreach ($check->getResults() as $result) {
+ $state = $result->getState()->getName();
+ $ul->add(Html::tag('li', [
+ 'class' => 'state state-' . strtolower($state)
+ ], $this->highlightNames($result->getOutput()))->setSeparator(' '));
+ }
+ $this->state->raise($check->getState());
+ }
+ }
+
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ protected function colorizeState($state)
+ {
+ return Html::tag('span', ['class' => 'badge state-' . strtolower($state)], $state);
+ }
+
+ protected function highlightNames($string)
+ {
+ $string = Html::escape($string);
+ return new HtmlString(preg_replace_callback(
+ "/'([^']+)'/",
+ [$this, 'highlightName'],
+ $string
+ ));
+ }
+
+ protected function highlightName($match)
+ {
+ return '"' . Html::tag('strong', $match[1]) . '"';
+ }
+
+ protected function getColorized($match)
+ {
+ return $this->colorizeState($match[1]);
+ }
+}
diff --git a/library/Director/Web/Widget/IcingaConfigDiff.php b/library/Director/Web/Widget/IcingaConfigDiff.php
new file mode 100644
index 0000000..800f1d9
--- /dev/null
+++ b/library/Director/Web/Widget/IcingaConfigDiff.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Diff\HtmlRenderer\SideBySideDiff;
+use gipfl\Diff\PhpDiff;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\ValidHtml;
+
+class IcingaConfigDiff extends HtmlDocument
+{
+ public function __construct(IcingaConfig $left, IcingaConfig $right)
+ {
+ foreach (static::getDiffs($left, $right) as $filename => $diff) {
+ $this->add([
+ Html::tag('h3', $filename),
+ $diff
+ ]);
+ }
+ }
+
+ /**
+ * @param IcingaConfig $oldConfig
+ * @param IcingaConfig $newConfig
+ * @return ValidHtml[]
+ */
+ public static function getDiffs(IcingaConfig $oldConfig, IcingaConfig $newConfig)
+ {
+ $oldFileNames = $oldConfig->getFileNames();
+ $newFileNames = $newConfig->getFileNames();
+
+ $fileNames = array_merge($oldFileNames, $newFileNames);
+
+ $diffs = [];
+ foreach ($fileNames as $filename) {
+ if (in_array($filename, $oldFileNames)) {
+ $left = $oldConfig->getFile($filename)->getContent();
+ } else {
+ $left = '';
+ }
+
+ if (in_array($filename, $newFileNames)) {
+ $right = $newConfig->getFile($filename)->getContent();
+ } else {
+ $right = '';
+ }
+ if ($left === $right) {
+ continue;
+ }
+
+ $diffs[$filename] = new SideBySideDiff(new PhpDiff($left, $right));
+ }
+
+ return $diffs;
+ }
+}
diff --git a/library/Director/Web/Widget/IcingaObjectInspection.php b/library/Director/Web/Widget/IcingaObjectInspection.php
new file mode 100644
index 0000000..61f3567
--- /dev/null
+++ b/library/Director/Web/Widget/IcingaObjectInspection.php
@@ -0,0 +1,254 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\PlainObjectRenderer;
+use Icinga\Module\Director\Web\Table\DbHelper;
+use stdClass;
+
+class IcingaObjectInspection extends BaseHtmlElement
+{
+ use DbHelper;
+ use TranslationHelper;
+
+ protected $tag = 'div';
+
+ /** @var Db */
+ protected $db;
+
+ /** @var stdClass */
+ protected $object;
+
+ public function __construct(stdClass $object, Db $db)
+ {
+ $this->object = $object;
+ $this->db = $db;
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function assemble()
+ {
+ $attrs = $this->object->attrs;
+ if (isset($attrs->source_location)) {
+ $this->renderSourceLocation($attrs->source_location);
+ }
+ if (isset($attrs->last_check_result)) {
+ $this->renderLastCheckResult($attrs->last_check_result);
+ }
+
+ $this->renderObjectAttributes($attrs);
+ // $this->add(Html::tag('pre', null, PlainObjectRenderer::render($this->object)));
+ }
+
+ /**
+ * @param $result
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderLastCheckResult($result)
+ {
+ $this->add(Html::tag('h2', null, $this->translate('Last Check Result')));
+ $this->renderCheckResultDetails($result);
+ if (property_exists($result, 'command')) {
+ $this->renderExecutedCommand($result->command);
+ }
+ }
+
+ /**
+ * @param array|string $command
+ *
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderExecutedCommand($command)
+ {
+ if (is_array($command)) {
+ $command = implode(' ', array_map('escapeshellarg', $command));
+ }
+ $this->add([
+ Html::tag('h3', null, 'Executed Command'),
+ $this->formatConsole($command)
+ ]);
+ }
+
+ protected function renderCheckResultDetails($result)
+ {
+ }
+
+ /**
+ * @param $attrs
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderObjectAttributes($attrs)
+ {
+ $blacklist = [
+ 'last_check_result',
+ 'source_location',
+ 'templates',
+ ];
+
+ $linked = [
+ 'check_command',
+ 'groups',
+ ];
+
+ $info = new NameValueTable();
+ foreach ($attrs as $key => $value) {
+ if (in_array($key, $blacklist)) {
+ continue;
+ }
+ if ($key === 'groups') {
+ $info->addNameValueRow($key, $this->linkGroups($value));
+ } elseif (in_array($key, $linked)) {
+ $info->addNameValueRow($key, $this->renderLinkedObject($key, $value));
+ } else {
+ $info->addNameValueRow($key, PlainObjectRenderer::render($value));
+ }
+ }
+
+ $this->add([
+ Html::tag('h2', null, 'Object Properties'),
+ $info
+ ]);
+ }
+
+ /**
+ * @param $key
+ * @param $objectName
+ * @return Link|Link[]
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function renderLinkedObject($key, $objectName)
+ {
+ $keys = [
+ 'check_command' => ['CheckCommand', 'CheckCommands'],
+ 'event_command' => ['EventCommand', 'EventCommands'],
+ 'notification_command' => ['NotificationCommand', 'NotificationCommands'],
+ ];
+ $type = $keys[$key];
+
+ if ($key === 'groups') {
+ return $this->linkGroups($objectName);
+ } else {
+ $singular = $type[0];
+ $plural = $type[1];
+
+ return Link::create($objectName, 'director/inspect/object', [
+ 'type' => $singular,
+ 'plural' => $plural,
+ 'name' => $objectName
+ ]);
+ }
+ }
+
+ /**
+ * @param $groups
+ * @return Link[]
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkGroups($groups)
+ {
+ if ($groups === null) {
+ return [];
+ }
+
+ $singular = $this->object->type . 'Group';
+ $plural = $singular . "s";
+
+ $links = [];
+
+ foreach ($groups as $name) {
+ $links[] = Link::create($name, 'director/inspect/object', [
+ 'type' => $singular,
+ 'plural' => $plural,
+ 'name' => $name
+ ]);
+ }
+
+ return $links;
+ }
+
+ /**
+ * @param stdClass $source
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderSourceLocation(stdClass $source)
+ {
+ $findRelative = 'api/packages/director';
+ $this->add(Html::tag('h2')->add('Source Location'));
+ $pos = strpos($source->path, $findRelative);
+
+ if (false === $pos) {
+ $this->add(Html::tag('p', null, Html::sprintf(
+ 'The configuration for this object has not been rendered by'
+ . ' Icinga Director. You can find it on line %s in %s.',
+ Html::tag('strong', null, $source->first_line),
+ Html::tag('strong', null, $source->path)
+ )));
+ } else {
+ $relativePath = substr($source->path, $pos + strlen($findRelative) + 1);
+ $parts = explode('/', $relativePath);
+ $stageName = array_shift($parts);
+ $relativePath = implode('/', $parts);
+ $source->director_relative = $relativePath;
+ $deployment = $this->loadDeploymentForStage($stageName);
+
+ $this->add(Html::tag('p')->add(Html::sprintf(
+ 'The configuration for this object has been rendered by Icinga'
+ . ' Director %s to %s',
+ DateFormatter::timeAgo(strtotime($deployment->start_time, false)),
+ $this->linkToSourceLocation($deployment, $source)
+ )));
+ }
+ }
+
+ protected function loadDeploymentForStage($stageName)
+ {
+ $db = $this->db->getDbAdapter();
+ $query = $db->select()->from(
+ ['dl' => 'director_deployment_log'],
+ ['id', 'start_time', 'config_checksum']
+ )->where('stage_name = ?', $stageName)->order('id DESC')->limit(1);
+
+ return $db->fetchRow($query);
+ }
+
+ /**
+ * @param $deployment
+ * @param $source
+ * @return Link
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkToSourceLocation($deployment, $source)
+ {
+ $filename = $source->director_relative;
+
+ return Link::create(
+ sprintf('%s:%s', $filename, $source->first_line),
+ 'director/config/file',
+ [
+ 'config_checksum' => $this->getChecksum($deployment->config_checksum),
+ 'deployment_id' => $deployment->id,
+ 'backTo' => 'deployment',
+ 'file_path' => $filename,
+ 'highlight' => $source->first_line,
+ 'highlightSeverity' => 'ok'
+ ]
+ );
+ }
+
+ protected function formatConsole($output)
+ {
+ return Html::tag('pre', ['class' => 'logfile'], $output);
+ }
+}
diff --git a/library/Director/Web/Widget/ImportSourceDetails.php b/library/Director/Web/Widget/ImportSourceDetails.php
new file mode 100644
index 0000000..32eef7f
--- /dev/null
+++ b/library/Director/Web/Widget/ImportSourceDetails.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Web\Widget\Hint;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Forms\ImportCheckForm;
+use Icinga\Module\Director\Forms\ImportRunForm;
+use Icinga\Module\Director\Objects\ImportSource;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class ImportSourceDetails extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $source;
+
+ public function __construct(ImportSource $source)
+ {
+ $this->source = $source;
+ }
+
+ protected function assemble()
+ {
+ $source = $this->source;
+ $description = $source->get('description');
+ if ($description !== null && strlen($description)) {
+ $this->add(Html::tag('p', null, $description));
+ }
+
+ switch ($source->get('import_state')) {
+ case 'unknown':
+ $this->add(Hint::warning($this->translate(
+ "It's currently unknown whether we are in sync with this Import Source."
+ . ' You should either check for changes or trigger a new Import Run.'
+ )));
+ break;
+ case 'in-sync':
+ $this->add(Hint::ok(sprintf(
+ $this->translate(
+ 'This Import Source was last found to be in sync at %s.'
+ ),
+ $source->last_attempt
+ )));
+ // TODO: check whether...
+ // - there have been imports since then, differing from former ones
+ // - there have been activities since then
+ break;
+ case 'pending-changes':
+ $this->add(Hint::warning($this->translate(
+ 'There are pending changes for this Import Source. You should trigger a new'
+ . ' Import Run.'
+ )));
+ break;
+ case 'failing':
+ $this->add(Hint::error(sprintf(
+ $this->translate(
+ 'This Import Source failed when last checked at %s: %s'
+ ),
+ $source->last_attempt,
+ $source->last_error_message
+ )));
+ break;
+ default:
+ $this->add(Hint::error(sprintf(
+ $this->translate('This Import Source has an invalid state: %s'),
+ $source->get('import_state')
+ )));
+ }
+
+ $this->add(
+ ImportCheckForm::load()
+ ->setImportSource($source)
+ ->handleRequest()
+ );
+ $this->add(
+ ImportRunForm::load()
+ ->setImportSource($source)
+ ->handleRequest()
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/InspectPackages.php b/library/Director/Web/Widget/InspectPackages.php
new file mode 100644
index 0000000..f9b8864
--- /dev/null
+++ b/library/Director/Web/Widget/InspectPackages.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use ipl\Html\Html;
+use ipl\Html\Table;
+
+class InspectPackages
+{
+ use TranslationHelper;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var string */
+ protected $baseUrl;
+
+ public function __construct(Db $db, $baseUrl)
+ {
+ $this->db = $db;
+ $this->baseUrl = $baseUrl;
+ }
+
+ public function getContent(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null)
+ {
+ if ($endpoint === null) {
+ return $this->getRootEndpoints();
+ } elseif ($package === null) {
+ return $this->getPackages($endpoint);
+ } elseif ($stage === null) {
+ return $this->getStages($endpoint, $package);
+ } elseif ($file === null) {
+ return $this->getFiles($endpoint, $package, $stage);
+ } else {
+ return $this->getFile($endpoint, $package, $stage, $file);
+ }
+ }
+
+ public function getTitle(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null)
+ {
+ if ($endpoint === null) {
+ return $this->translate('Endpoint in your Root Zone');
+ } elseif ($package === null) {
+ return \sprintf($this->translate('Packages on Endpoint: %s'), $endpoint->getObjectName());
+ } elseif ($stage === null) {
+ return \sprintf($this->translate('Stages in Package: %s'), $package);
+ } elseif ($file === null) {
+ return \sprintf($this->translate('Files in Stage: %s'), $stage);
+ } else {
+ return \sprintf($this->translate('File Content: %s'), $file);
+ }
+ }
+
+ public function getBreadCrumb(IcingaEndpoint $endpoint = null, $package = null, $stage = null)
+ {
+ $parts = [
+ 'endpoint' => $endpoint === null ? null : $endpoint->getObjectName(),
+ 'package' => $package,
+ 'stage' => $stage,
+ ];
+
+ $params = [];
+ // No root zone link for now:
+ // $result = [Link::create($this->translate('Root Zone'), $this->baseUrl)];
+ $result = [Html::tag('a', ['href' => '#'], $this->translate('Root Zone'))];
+ foreach ($parts as $name => $value) {
+ if ($value === null) {
+ break;
+ }
+ $params[$name] = $value;
+ $result[] = Link::create($value, $this->baseUrl, $params);
+ }
+
+ return Html::tag('ul', ['class' => 'breadcrumb'], Html::wrapEach($result, 'li'));
+ }
+
+ protected function getRootEndpoints()
+ {
+ $table = $this->prepareTable();
+ foreach ($this->db->getEndpointNamesInDeploymentZone() as $name) {
+ $table->add(Table::row([
+ Link::create($name, $this->baseUrl, [
+ 'endpoint' => $name,
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getPackages(IcingaEndpoint $endpoint)
+ {
+ $table = $this->prepareTable();
+ $api = $endpoint->api();
+ foreach ($api->getPackages() as $package) {
+ $table->add(Table::row([
+ Link::create($package->name, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package->name,
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getStages(IcingaEndpoint $endpoint, $packageName)
+ {
+ $table = $this->prepareTable();
+ $api = $endpoint->api();
+ foreach ($api->getPackages() as $package) {
+ if ($package->name !== $packageName) {
+ continue;
+ }
+ foreach ($package->stages as $stage) {
+ $label = [$stage];
+ if ($stage === $package->{'active-stage'}) {
+ $label[] = Html::tag('small', [' (', $this->translate('active'), ')']);
+ }
+
+ $table->add(Table::row([
+ Link::create($label, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package->name,
+ 'stage' => $stage
+ ])
+ ]));
+ }
+ }
+
+ return $table;
+ }
+
+ protected function getFiles(IcingaEndpoint $endpoint, $package, $stage)
+ {
+ $table = $this->prepareTable();
+ $table->getAttributes()->set('data-base-target', '_next');
+ foreach ($endpoint->api()->listStageFiles($stage, $package) as $filename) {
+ $table->add($table->row([
+ Link::create($filename, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package,
+ 'stage' => $stage,
+ 'file' => $filename
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getFile(IcingaEndpoint $endpoint, $package, $stage, $file)
+ {
+ return Html::tag('pre', $endpoint->api()->getStagedFile($stage, $file, $package));
+ }
+
+ protected function prepareTable($headerCols = [])
+ {
+ $table = new Table();
+ $table->addAttributes([
+ 'class' => ['common-table', 'table-row-selectable'],
+ 'data-base-target' => '_self'
+ ]);
+ if (! empty($headerCols)) {
+ $table->add($table::row($headerCols, null, 'th'));
+ }
+
+ return $table;
+ }
+}
diff --git a/library/Director/Web/Widget/JobDetails.php b/library/Director/Web/Widget/JobDetails.php
new file mode 100644
index 0000000..3a530a2
--- /dev/null
+++ b/library/Director/Web/Widget/JobDetails.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Date\DateFormatter;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Objects\DirectorJob;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class JobDetails extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /**
+ * JobDetails constructor.
+ * @param DirectorJob $job
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function __construct(DirectorJob $job)
+ {
+ $runInterval = $job->get('run_interval');
+ if ($job->hasBeenDisabled()) {
+ $this->add(Hint::error(sprintf(
+ $this->translate(
+ 'This job would run every %ds. It has been disabled and will'
+ . ' therefore not be executed as scheduled'
+ ),
+ $runInterval
+ )));
+ } else {
+ //$class = $job->job(); echo $class::getDescription()
+ $msg = $job->isPending()
+ ? sprintf(
+ $this->translate('This job runs every %ds and is currently pending'),
+ $runInterval
+ )
+ : sprintf(
+ $this->translate('This job runs every %ds.'),
+ $runInterval
+ );
+ $this->add(Html::tag('p', null, $msg));
+ }
+
+ $tsLastAttempt = $job->get('ts_last_attempt');
+ if ($tsLastAttempt) {
+ $ts = \strtotime($tsLastAttempt);
+ $timeAgo = Html::tag('span', [
+ 'class' => 'time-ago',
+ 'title' => DateFormatter::formatDateTime($ts)
+ ], DateFormatter::timeAgo($ts));
+ if ($job->get('last_attempt_succeeded') === 'y') {
+ $this->add(Hint::ok(Html::sprintf(
+ $this->translate('The last attempt succeeded %s'),
+ $timeAgo
+ )));
+ } else {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate('The last attempt failed %s: %s'),
+ $timeAgo,
+ $job->get('last_error_message')
+ )));
+ }
+ } else {
+ $this->add(Hint::warning($this->translate('This job has not been executed yet')));
+ }
+ }
+}
diff --git a/library/Director/Web/Widget/ListItem.php b/library/Director/Web/Widget/ListItem.php
new file mode 100644
index 0000000..ec326cc
--- /dev/null
+++ b/library/Director/Web/Widget/ListItem.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+
+class ListItem extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ /**
+ * @param ValidHtml|array|string $content
+ * @param Attributes|array $attributes
+ *
+ * @return $this
+ */
+ public function addItem($content, $attributes = null)
+ {
+ return $this->add(
+ Html::tag('li', $attributes, $content)
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/NotInBranchedHint.php b/library/Director/Web/Widget/NotInBranchedHint.php
new file mode 100644
index 0000000..222934b
--- /dev/null
+++ b/library/Director/Web/Widget/NotInBranchedHint.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\Html;
+
+class NotInBranchedHint extends Hint
+{
+ use TranslationHelper;
+
+ public function __construct($forbiddenAction, Branch $branch, Auth $auth)
+ {
+ parent::__construct(Html::sprintf(
+ $this->translate('%s is not available while being in a Configuration Branch: %s'),
+ $forbiddenAction,
+ Branch::requireHook()->linkToBranch($branch, $auth, $branch->getName())
+ ), 'info');
+ }
+}
diff --git a/library/Director/Web/Widget/OrderedList.php b/library/Director/Web/Widget/OrderedList.php
new file mode 100644
index 0000000..8f888de
--- /dev/null
+++ b/library/Director/Web/Widget/OrderedList.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+class OrderedList extends AbstractList
+{
+ protected $tag = 'ol';
+}
diff --git a/library/Director/Web/Widget/ShowConfigFile.php b/library/Director/Web/Widget/ShowConfigFile.php
new file mode 100644
index 0000000..77d32cf
--- /dev/null
+++ b/library/Director/Web/Widget/ShowConfigFile.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigFile;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+
+class ShowConfigFile extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $file;
+
+ protected $highlight;
+
+ protected $highlightSeverity;
+
+ public function __construct(
+ IcingaConfigFile $file,
+ $highlight = null,
+ $highlightSeverity = null
+ ) {
+ $this->file = $file;
+ $this->highlight = $highlight;
+ $this->highlightSeverity = $highlightSeverity;
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function assemble()
+ {
+ $source = $this->linkObjects(Html::escape($this->file->getContent()));
+ if ($this->highlight) {
+ $source = $this->highlight(
+ $source,
+ $this->highlight,
+ $this->highlightSeverity
+ );
+ }
+
+ $this->add(Html::tag(
+ 'pre',
+ ['class' => 'generated-config'],
+ new HtmlString($source)
+ ));
+ }
+
+ /**
+ * @param $match
+ * @return string
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkObject($match)
+ {
+ if ($match[2] === 'Service') {
+ return $match[0];
+ }
+ $controller = $match[2];
+
+ if ($match[2] === 'CheckCommand') {
+ $controller = 'command';
+ }
+
+ $name = $this->decode($match[3]);
+ return sprintf(
+ '%s %s &quot;%s&quot; {',
+ $match[1],
+ $match[2],
+ Link::create(
+ $name,
+ 'director/' . $controller,
+ ['name' => $name],
+ ['data-base-target' => '_next']
+ )
+ );
+ }
+
+ protected function decode($str)
+ {
+ return htmlspecialchars_decode($str, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5);
+ }
+
+ protected function linkObjects($config)
+ {
+ $pattern = '/^(object|template)\s([A-Z][A-Za-z]*?)\s&quot;(.+?)&quot;\s{/m';
+
+ return preg_replace_callback(
+ $pattern,
+ [$this, 'linkObject'],
+ $config
+ );
+ }
+
+ protected function highlight($what, $line, $severity)
+ {
+ $lines = explode("\n", $what);
+ $lines[$line - 1] = '<span class="highlight ' . $severity . '">' . $lines[$line - 1] . '</span>';
+ return implode("\n", $lines);
+ }
+}
diff --git a/library/Director/Web/Widget/SyncRunDetails.php b/library/Director/Web/Widget/SyncRunDetails.php
new file mode 100644
index 0000000..408e8f6
--- /dev/null
+++ b/library/Director/Web/Widget/SyncRunDetails.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\SyncRun;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use function sprintf;
+
+class SyncRunDetails extends NameValueTable
+{
+ use TranslationHelper;
+
+ const URL_ACTIVITIES = 'director/config/activities';
+
+ /** @var SyncRun */
+ protected $run;
+
+ public function __construct(SyncRun $run)
+ {
+ $this->run = $run;
+ $this->getAttributes()->add('data-base-target', '_next'); // eigentlich nur runSummary
+ $this->addNameValuePairs([
+ $this->translate('Start time') => $run->get('start_time'),
+ $this->translate('Duration') => sprintf('%.2fs', $run->get('duration_ms') / 1000),
+ $this->translate('Activity') => $this->runSummary($run)
+ ]);
+ }
+
+ /**
+ * @param SyncRun $run
+ * @return array
+ */
+ protected function runSummary(SyncRun $run)
+ {
+ $html = [];
+ $total = $run->countActivities();
+ if ($total === 0) {
+ $html[] = $this->translate('No changes have been made');
+ } else {
+ if ($total === 1) {
+ $html[] = $this->translate('One object has been modified');
+ } else {
+ $html[] = sprintf(
+ $this->translate('%s objects have been modified'),
+ $total
+ );
+ }
+
+ /** @var Db $db */
+ $db = $run->getConnection();
+ $formerId = $db->fetchActivityLogIdByChecksum($run->get('last_former_activity'));
+ if ($formerId === null) {
+ return $html;
+ }
+ $lastId = $db->fetchActivityLogIdByChecksum($run->get('last_related_activity'));
+
+ if ($formerId !== $lastId) {
+ $idRangeEx = sprintf(
+ 'id>%d&id<=%d',
+ $formerId,
+ $lastId
+ );
+ } else {
+ $idRangeEx = null;
+ }
+
+ $links = new HtmlDocument();
+ $links->setSeparator(', ');
+ $links->add([
+ $this->activitiesLink(
+ 'objects_created',
+ $this->translate('%d created'),
+ DirectorActivityLog::ACTION_CREATE,
+ $idRangeEx
+ ),
+ $this->activitiesLink(
+ 'objects_modified',
+ $this->translate('%d modified'),
+ DirectorActivityLog::ACTION_MODIFY,
+ $idRangeEx
+ ),
+ $this->activitiesLink(
+ 'objects_deleted',
+ $this->translate('%d deleted'),
+ DirectorActivityLog::ACTION_DELETE,
+ $idRangeEx
+ ),
+ ]);
+
+ if ($idRangeEx && count($links) > 1) {
+ $links->add(new Link(
+ $this->translate('Show all actions'),
+ self::URL_ACTIVITIES,
+ ['idRangeEx' => $idRangeEx]
+ ));
+ }
+
+ if (! $links->isEmpty()) {
+ $html[] = ': ';
+ $html[] = $links;
+ }
+ }
+
+ return $html;
+ }
+
+ protected function activitiesLink($key, $label, $action, $rangeFilter)
+ {
+ $count = $this->run->get($key);
+ if ($count > 0) {
+ if ($rangeFilter) {
+ return new Link(
+ sprintf($label, $count),
+ self::URL_ACTIVITIES,
+ ['action' => $action, 'idRangeEx' => $rangeFilter]
+ );
+ }
+
+ return sprintf($label, $count);
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Web/Widget/UnorderedList.php b/library/Director/Web/Widget/UnorderedList.php
new file mode 100644
index 0000000..f01dbe3
--- /dev/null
+++ b/library/Director/Web/Widget/UnorderedList.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+class UnorderedList extends AbstractList
+{
+ protected $tag = 'ul';
+}