summaryrefslogtreecommitdiffstats
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--application/clicommands/MigrateCommand.php835
-rw-r--r--application/controllers/CommandTransportController.php154
-rw-r--r--application/controllers/CommentController.php72
-rw-r--r--application/controllers/CommentsController.php197
-rw-r--r--application/controllers/ConfigController.php63
-rw-r--r--application/controllers/DowntimeController.php84
-rw-r--r--application/controllers/DowntimesController.php203
-rw-r--r--application/controllers/ErrorController.php97
-rw-r--r--application/controllers/EventController.php71
-rw-r--r--application/controllers/HealthController.php115
-rw-r--r--application/controllers/HistoryController.php139
-rw-r--r--application/controllers/HostController.php293
-rw-r--r--application/controllers/HostgroupController.php82
-rw-r--r--application/controllers/HostgroupsController.php145
-rw-r--r--application/controllers/HostsController.php242
-rw-r--r--application/controllers/MigrateController.php161
-rw-r--r--application/controllers/NotificationsController.php136
-rw-r--r--application/controllers/ServiceController.php245
-rw-r--r--application/controllers/ServicegroupController.php89
-rw-r--r--application/controllers/ServicegroupsController.php133
-rw-r--r--application/controllers/ServicesController.php436
-rw-r--r--application/controllers/TacticalController.php94
-rw-r--r--application/controllers/UserController.php48
-rw-r--r--application/controllers/UsergroupController.php48
-rw-r--r--application/controllers/UsergroupsController.php95
-rw-r--r--application/controllers/UsersController.php97
-rw-r--r--application/forms/ApiTransportForm.php102
-rw-r--r--application/forms/Command/CommandForm.php179
-rw-r--r--application/forms/Command/Instance/ToggleInstanceFeaturesForm.php154
-rw-r--r--application/forms/Command/Object/AcknowledgeProblemForm.php210
-rw-r--r--application/forms/Command/Object/AddCommentForm.php162
-rw-r--r--application/forms/Command/Object/CheckNowForm.php72
-rw-r--r--application/forms/Command/Object/DeleteCommentForm.php75
-rw-r--r--application/forms/Command/Object/DeleteDowntimeForm.php90
-rw-r--r--application/forms/Command/Object/ProcessCheckResultForm.php156
-rw-r--r--application/forms/Command/Object/RemoveAcknowledgementForm.php77
-rw-r--r--application/forms/Command/Object/ScheduleCheckForm.php137
-rw-r--r--application/forms/Command/Object/ScheduleHostDowntimeForm.php119
-rw-r--r--application/forms/Command/Object/ScheduleServiceDowntimeForm.php267
-rw-r--r--application/forms/Command/Object/SendCustomNotificationForm.php125
-rw-r--r--application/forms/Command/Object/ToggleObjectFeaturesForm.php186
-rw-r--r--application/forms/DatabaseConfigForm.php33
-rw-r--r--application/forms/Navigation/ActionForm.php58
-rw-r--r--application/forms/Navigation/IcingadbHostActionForm.php10
-rw-r--r--application/forms/Navigation/IcingadbServiceActionForm.php10
-rw-r--r--application/forms/RedisConfigForm.php606
-rw-r--r--application/forms/SetAsBackendForm.php34
-rw-r--r--application/views/scripts/joystickPagination-icingadb.phtml162
-rw-r--r--application/views/scripts/services/grid-flipped.phtml149
-rw-r--r--application/views/scripts/services/grid.phtml150
50 files changed, 7697 insertions, 0 deletions
diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php
new file mode 100644
index 0000000..6d034ee
--- /dev/null
+++ b/application/clicommands/MigrateCommand.php
@@ -0,0 +1,835 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Clicommands;
+
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Cli\Command;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\NotWritableError;
+use Icinga\Module\Icingadb\Compat\UrlMigrator;
+use Icinga\Util\DirectoryIterator;
+use Icinga\Web\Request;
+use ipl\Stdlib\Str;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class MigrateCommand extends Command
+{
+ /** @var bool Skip the migration, only perform transformations */
+ protected $skipMigration = false;
+
+ public function init(): void
+ {
+ Logger::getInstance()->setLevel(Logger::INFO);
+ }
+
+ /**
+ * Migrate monitoring navigation items to Icinga DB Web
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate navigation [options]
+ *
+ * REQUIRED OPTIONS:
+ *
+ * --user=<name> Migrate navigation items whose owner matches the given
+ * name or owners matching the given pattern. Wildcard
+ * matching by `*` possible.
+ *
+ * OPTIONS:
+ *
+ * --override Replace existing or already migrated items
+ * (Attention: Actions are not backed up)
+ *
+ * --no-backup Remove monitoring actions and don't back up menu items
+ */
+ public function navigationAction(): void
+ {
+ /** @var string $user */
+ $user = $this->params->getRequired('user');
+ $noBackup = $this->params->get('no-backup');
+
+ $preferencesPath = Config::resolvePath('preferences');
+ $sharedNavigation = Config::resolvePath('navigation');
+ if (! file_exists($preferencesPath) && ! file_exists($sharedNavigation)) {
+ Logger::info('There are no user navigation items to migrate');
+ return;
+ }
+
+ $rc = 0;
+ $directories = file_exists($preferencesPath) ? new DirectoryIterator($preferencesPath) : [];
+
+ $anythingChanged = false;
+
+ /** @var string $directory */
+ foreach ($directories as $directory) {
+ /** @var string $username */
+ $username = $directories->key() === false ? '' : $directories->key();
+ if (fnmatch($user, $username) === false) {
+ continue;
+ }
+
+ $menuItems = $this->readFromIni($directory . '/menu.ini', $rc);
+ $hostActions = $this->readFromIni($directory . '/host-actions.ini', $rc);
+ $serviceActions = $this->readFromIni($directory . '/service-actions.ini', $rc);
+ $icingadbHostActions = $this->readFromIni($directory . '/icingadb-host-actions.ini', $rc);
+ $icingadbServiceActions = $this->readFromIni($directory . '/icingadb-service-actions.ini', $rc);
+
+ $menuUpdated = false;
+ $originalMenuItems = $this->readFromIni($directory . '/menu.ini', $rc);
+
+ Logger::info(
+ 'Transforming legacy wildcard filters of existing Icinga DB Web items for user "%s"',
+ $username
+ );
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->transformNavigationItems($menuItems, $username, $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $icingadbHostActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems($icingadbHostActions, $username, $rc);
+ }
+
+ if (! $icingadbServiceActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems(
+ $icingadbServiceActions,
+ $username,
+ $rc
+ );
+ }
+
+ if (! $this->skipMigration) {
+ Logger::info('Migrating monitoring navigation items for user "%s" to Icinga DB Web', $username);
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->migrateNavigationItems($menuItems, $username, $directory . '/menu.ini', $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $hostActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $hostActions,
+ $username,
+ $directory . '/icingadb-host-actions.ini',
+ $rc
+ );
+ }
+
+ if (! $serviceActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $serviceActions,
+ $username,
+ $directory . '/icingadb-service-actions.ini',
+ $rc
+ );
+ }
+ }
+
+ if ($menuUpdated && ! $noBackup) {
+ $this->createBackupIni("$directory/menu", $originalMenuItems);
+ }
+ }
+
+ // Start migrating shared navigation items
+ $menuItems = $this->readFromIni($sharedNavigation . '/menu.ini', $rc);
+ $hostActions = $this->readFromIni($sharedNavigation . '/host-actions.ini', $rc);
+ $serviceActions = $this->readFromIni($sharedNavigation . '/service-actions.ini', $rc);
+ $icingadbHostActions = $this->readFromIni($sharedNavigation . '/icingadb-host-actions.ini', $rc);
+ $icingadbServiceActions = $this->readFromIni($sharedNavigation . '/icingadb-service-actions.ini', $rc);
+
+ $menuUpdated = false;
+ $originalMenuItems = $this->readFromIni($sharedNavigation . '/menu.ini', $rc);
+
+ Logger::info('Transforming legacy wildcard filters of existing shared Icinga DB Web navigation items');
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->transformNavigationItems($menuItems, $user, $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $icingadbHostActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems($icingadbHostActions, $user, $rc);
+ }
+
+ if (! $icingadbServiceActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems(
+ $icingadbServiceActions,
+ $user,
+ $rc
+ );
+ }
+
+ if (! $this->skipMigration) {
+ Logger::info('Migrating shared monitoring navigation items to the Icinga DB Web items');
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->migrateNavigationItems($menuItems, $user, $sharedNavigation . '/menu.ini', $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $hostActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $hostActions,
+ $user,
+ $sharedNavigation . '/icingadb-host-actions.ini',
+ $rc
+ );
+ }
+
+ if (! $serviceActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $serviceActions,
+ $user,
+ $sharedNavigation . '/icingadb-service-actions.ini',
+ $rc
+ );
+ }
+ }
+
+ if ($menuUpdated && ! $noBackup) {
+ $this->createBackupIni("$sharedNavigation/menu", $originalMenuItems);
+ }
+
+ if ($rc > 0) {
+ if ($this->skipMigration) {
+ Logger::error('Failed to transform some icingadb navigation items');
+ } else {
+ Logger::error('Failed to migrate some monitoring navigation items');
+ }
+
+ exit($rc);
+ }
+
+ if (! $anythingChanged) {
+ Logger::info('Nothing to do');
+ } elseif ($this->skipMigration) {
+ Logger::info('Successfully transformed all icingadb navigation item filters');
+ } else {
+ Logger::info('Successfully migrated all monitoring navigation items');
+ }
+ }
+
+
+ /**
+ * Migrate monitoring restrictions and permissions to Icinga DB Web
+ *
+ * Migrated roles do not grant general or full access to users afterward.
+ * It is recommended to review any changes made by this command, before
+ * manually granting access.
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate role [options]
+ *
+ * REQUIRED OPTIONS: (Use either, not both)
+ *
+ * --group=<name> Update roles that are assigned to the given group or to
+ * groups matching the pattern. Wildcard matching by `*`
+ * possible.
+ *
+ * --role=<name> Update role with the given name or roles whose names
+ * match the pattern. Wildcard matching by `*` possible.
+ *
+ * OPTIONS:
+ *
+ * --override Reset any existing Icinga DB Web rules
+ *
+ * --no-backup Don't back up roles
+ */
+ public function roleAction(): void
+ {
+ /** @var ?bool $override */
+ $override = $this->params->get('override');
+ $noBackup = $this->params->get('no-backup');
+
+ /** @var ?string $groupName */
+ $groupName = $this->params->get('group');
+ /** @var ?string $roleName */
+ $roleName = $this->params->get('role');
+
+ if ($roleName === null && $groupName === null) {
+ $this->fail("One of the parameters 'group' or 'role' must be supplied");
+ } elseif ($roleName !== null && $groupName !== null) {
+ $this->fail("Use either 'group' or 'role'. Both cannot be used as role overrules group.");
+ }
+
+ $rc = 0;
+ $changed = false;
+
+ $restrictions = Config::$configDir . '/roles.ini';
+ $rolesConfig = $this->readFromIni($restrictions, $rc);
+ $monitoringRestriction = 'monitoring/filter/objects';
+ $monitoringPropertyBlackList = 'monitoring/blacklist/properties';
+ $icingadbRestrictions = [
+ 'objects' => 'icingadb/filter/objects',
+ 'hosts' => 'icingadb/filter/hosts',
+ 'services' => 'icingadb/filter/services'
+ ];
+
+ $icingadbPropertyDenyList = 'icingadb/denylist/variables';
+ foreach ($rolesConfig as $name => $role) {
+ /** @var string[] $role */
+ $role = iterator_to_array($role);
+
+ if ($roleName === '*' || $groupName === '*') {
+ $roleMatch = true;
+ } elseif ($roleName !== null && fnmatch($roleName, $name)) {
+ $roleMatch = true;
+ } elseif ($groupName !== null && isset($role['groups'])) {
+ $roleGroups = array_map('trim', explode(',', $role['groups']));
+ $roleMatch = false;
+ foreach ($roleGroups as $roleGroup) {
+ if (fnmatch($groupName, $roleGroup)) {
+ $roleMatch = true;
+ break;
+ }
+ }
+ } else {
+ $roleMatch = false;
+ }
+
+ if ($roleMatch && ! $this->skipMigration && $this->shouldUpdateRole($role, $override)) {
+ if (isset($role[$monitoringRestriction])) {
+ Logger::info(
+ 'Migrating monitoring restriction filter for role "%s" to the Icinga DB Web restrictions',
+ $name
+ );
+ $transformedFilter = UrlMigrator::transformFilter(
+ QueryString::parse($role[$monitoringRestriction])
+ );
+
+ if ($transformedFilter) {
+ $role[$icingadbRestrictions['objects']] = QueryString::render($transformedFilter);
+ $changed = true;
+ }
+ }
+
+ if (isset($role[$monitoringPropertyBlackList])) {
+ Logger::info(
+ 'Migrating monitoring blacklisted properties for role "%s" to the Icinga DB Web deny list',
+ $name
+ );
+
+ $icingadbProperties = [];
+ foreach (explode(',', $role[$monitoringPropertyBlackList]) as $property) {
+ $icingadbProperties[] = preg_replace('/^(?:host|service)\.vars\./i', '', $property, 1);
+ }
+
+ $role[$icingadbPropertyDenyList] = str_replace(
+ '**',
+ '*',
+ implode(',', array_unique($icingadbProperties))
+ );
+
+ $changed = true;
+ }
+
+ if (isset($role['permissions'])) {
+ $updatedPermissions = [];
+ Logger::info(
+ 'Migrating monitoring permissions for role "%s" to the Icinga DB Web permissions',
+ $name
+ );
+
+ if (strpos($role['permissions'], 'monitoring')) {
+ $monitoringProtection = Config::module('monitoring')
+ ->get('security', 'protected_customvars');
+
+ if ($monitoringProtection !== null) {
+ $role['icingadb/protect/variables'] = $monitoringProtection;
+ $changed = true;
+ }
+ }
+
+ foreach (explode(',', $role['permissions']) as $permission) {
+ if (Str::startsWith($permission, 'icingadb/') || $permission === 'module/icingadb') {
+ continue;
+ } elseif (Str::startsWith($permission, 'monitoring/command/')) {
+ $changed = true;
+ $updatedPermissions[] = $permission;
+ $updatedPermissions[] = str_replace('monitoring/', 'icingadb/', $permission);
+ } elseif ($permission === 'no-monitoring/contacts') {
+ $changed = true;
+ $updatedPermissions[] = $permission;
+ $role['icingadb/denylist/routes'] = 'users,usergroups';
+ } else {
+ $updatedPermissions[] = $permission;
+ }
+ }
+
+ $role['permissions'] = implode(',', $updatedPermissions);
+ }
+
+ if (isset($role['refusals']) && is_string($role['refusals'])) {
+ $updatedRefusals = [];
+ Logger::info(
+ 'Migrating monitoring refusals for role "%s" to the Icinga DB Web refusals',
+ $name
+ );
+
+ foreach (explode(',', $role['refusals']) as $refusal) {
+ if (Str::startsWith($refusal, 'icingadb/') || $refusal === 'module/icingadb') {
+ continue;
+ } elseif (Str::startsWith($refusal, 'monitoring/command/')) {
+ $changed = true;
+ $updatedRefusals[] = $refusal;
+ $updatedRefusals[] = str_replace('monitoring/', 'icingadb/', $refusal);
+ } else {
+ $updatedRefusals[] = $refusal;
+ }
+ }
+
+ $role['refusals'] = implode(',', $updatedRefusals);
+ }
+ }
+
+ if ($roleMatch) {
+ foreach ($icingadbRestrictions as $object => $icingadbRestriction) {
+ if (isset($role[$icingadbRestriction]) && is_string($role[$icingadbRestriction])) {
+ $filter = QueryString::parse($role[$icingadbRestriction]);
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+
+ if ($filter) {
+ $filter = QueryString::render($filter);
+ if ($filter !== $role[$icingadbRestriction]) {
+ Logger::info(
+ 'Icinga Db Web restriction of role "%s" for %s changed from "%s" to "%s"',
+ $name,
+ $object,
+ $role[$icingadbRestriction],
+ $filter
+ );
+
+ $role[$icingadbRestriction] = $filter;
+ $changed = true;
+ }
+ }
+ }
+ }
+ }
+
+ $rolesConfig->setSection($name, $role);
+ }
+
+ if ($changed) {
+ if (! $noBackup) {
+ $this->createBackupIni(Config::$configDir . '/roles');
+ }
+
+ try {
+ $rolesConfig->saveIni();
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ if ($this->skipMigration) {
+ Logger::error('Failed to transform icingadb restrictions');
+ } else {
+ Logger::error('Failed to migrate monitoring restrictions');
+ }
+
+ exit(256);
+ }
+
+ if ($this->skipMigration) {
+ Logger::info('Successfully transformed all icingadb restrictions');
+ } else {
+ Logger::info('Successfully migrated monitoring restrictions and permissions in roles');
+ }
+ } else {
+ Logger::info('Nothing to do');
+ }
+ }
+
+ /**
+ * Migrate monitoring dashboards to Icinga DB Web
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate dashboard [options]
+ *
+ * REQUIRED OPTIONS:
+ *
+ * --user=<name> Migrate dashboards whose owner matches the given
+ * name or owners matching the given pattern. Wildcard
+ * matching by `*` possible.
+ *
+ * OPTIONS:
+ *
+ * --no-backup Don't back up dashboards
+ */
+ public function dashboardAction(): void
+ {
+ /** @var string $user */
+ $user = $this->params->getRequired('user');
+ $noBackup = $this->params->get('no-backup');
+
+ $dashboardsPath = Config::resolvePath('dashboards');
+ if (! file_exists($dashboardsPath)) {
+ Logger::info('There are no dashboards to migrate');
+ return;
+ }
+
+ $rc = 0;
+ $directories = new DirectoryIterator($dashboardsPath);
+
+ $anythingChanged = false;
+
+ /** @var string $directory */
+ foreach ($directories as $directory) {
+ /** @var string $userName */
+ $userName = $directories->key() === false ? '' : $directories->key();
+ if (fnmatch($user, $userName) === false) {
+ continue;
+ }
+
+ $dashboardsConfig = $this->readFromIni($directory . '/dashboard.ini', $rc);
+ $backupConfig = $this->readFromIni($directory . '/dashboard.ini', $rc);
+
+ Logger::info(
+ 'Migrating monitoring dashboards to Icinga DB Web dashboards for user "%s"',
+ $userName
+ );
+
+ $changed = false;
+ /** @var ConfigObject $dashboardConfig */
+ foreach ($dashboardsConfig->getConfigObject() as $name => $dashboardConfig) {
+ /** @var ?string $dashboardUrlString */
+ $dashboardUrlString = $dashboardConfig->get('url');
+ if ($dashboardUrlString !== null) {
+ $dashBoardUrl = Url::fromPath($dashboardUrlString, [], new Request());
+ if (! $this->skipMigration && Str::startsWith(ltrim($dashboardUrlString, '/'), 'monitoring/')) {
+ $dashboardConfig->url = UrlMigrator::transformUrl($dashBoardUrl)->getRelativeUrl();
+ $changed = true;
+ }
+
+ if (Str::startsWith(ltrim($dashboardUrlString, '/'), 'icingadb/')) {
+ $finalUrl = $dashBoardUrl->onlyWith(['sort', 'limit', 'view', 'columns', 'page']);
+ $params = $dashBoardUrl->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = QueryString::parse($params->toString());
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ if ($filter) {
+ $oldFilterString = $params->toString();
+ $newFilterString = QueryString::render($filter);
+
+ if ($oldFilterString !== $newFilterString) {
+ Logger::info(
+ 'Icinga Db Web filter of dashboard "%s" has changed from "%s" to "%s"',
+ $name,
+ $params->toString(),
+ QueryString::render($filter)
+ );
+ $finalUrl->setFilter($filter);
+
+ $dashboardConfig->url = $finalUrl->getRelativeUrl();
+ $changed = true;
+ }
+ }
+ }
+ }
+ }
+
+
+ if ($changed && $noBackup === null) {
+ $this->createBackupIni("$directory/dashboard", $backupConfig);
+ }
+
+ if ($changed) {
+ $anythingChanged = true;
+ }
+
+ try {
+ $dashboardsConfig->saveIni();
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ $rc = 256;
+ }
+ }
+
+ if ($rc > 0) {
+ if ($this->skipMigration) {
+ Logger::error('Failed to transform some icingadb dashboards');
+ } else {
+ Logger::error('Failed to migrate some monitoring dashboards');
+ }
+
+ exit($rc);
+ }
+
+ if (! $anythingChanged) {
+ Logger::info('Nothing to do');
+ } elseif ($this->skipMigration) {
+ Logger::info('Successfully transformed all icingadb dashboards');
+ } else {
+ Logger::info('Successfully migrated dashboards for all the matched users');
+ }
+ }
+
+ /**
+ * Migrate Icinga DB Web wildcard filters of navigation items, dashboards and roles
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate filter
+ *
+ * OPTIONS:
+ *
+ * --no-backup Don't back up menu items, dashboards and roles
+ */
+ public function filterAction(): void
+ {
+ $this->skipMigration = true;
+
+ $this->params->set('user', '*');
+ $this->navigationAction();
+ $this->dashboardAction();
+
+ $this->params->set('role', '*');
+ $this->roleAction();
+ }
+
+ private function transformNavigationItems(Config $config, string $owner, int &$rc): bool
+ {
+ $updated = false;
+ /** @var ConfigObject $newConfigObject */
+ foreach ($config->getConfigObject() as $section => $newConfigObject) {
+ /** @var string $configOwner */
+ $configOwner = $newConfigObject->get('owner') ?? '';
+ if ($configOwner && $configOwner !== $owner) {
+ continue;
+ }
+
+ if (
+ $newConfigObject->get('type') === 'icingadb-host-action'
+ || $newConfigObject->get('type') === 'icingadb-service-action'
+ ) {
+ /** @var ?string $legacyFilter */
+ $legacyFilter = $newConfigObject->get('filter');
+ if ($legacyFilter !== null) {
+ $filter = QueryString::parse($legacyFilter);
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ if ($filter) {
+ $filter = QueryString::render($filter);
+ if ($legacyFilter !== $filter) {
+ $newConfigObject->filter = $filter;
+ $updated = true;
+ Logger::info(
+ 'Icinga DB Web filter of action "%s" is changed from %s to "%s"',
+ $section,
+ $legacyFilter,
+ $filter
+ );
+ }
+ }
+ }
+ }
+
+ /** @var string $url */
+ $url = $newConfigObject->get('url');
+ if ($url && Str::startsWith(ltrim($url, '/'), 'icingadb/')) {
+ $url = Url::fromPath($url, [], new Request());
+ $finalUrl = $url->onlyWith(['sort', 'limit', 'view', 'columns', 'page']);
+ $params = $url->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = QueryString::parse($params->toString());
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ if ($filter) {
+ $oldFilterString = $params->toString();
+ $newFilterString = QueryString::render($filter);
+
+ if ($oldFilterString !== $newFilterString) {
+ Logger::info(
+ 'Icinga Db Web filter of navigation item "%s" has changed from "%s" to "%s"',
+ $section,
+ $oldFilterString,
+ $newFilterString
+ );
+
+ $newConfigObject->url = $finalUrl->setFilter($filter)->getRelativeUrl();
+ $updated = true;
+ }
+ }
+ }
+ }
+
+ if ($updated) {
+ try {
+ $config->saveIni();
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ $rc = 256;
+
+ return false;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Migrate the given config to the given new config path
+ *
+ * @param Config $config
+ * @param string $owner
+ * @param string $path
+ * @param int $rc
+ *
+ * @return bool
+ */
+ private function migrateNavigationItems(Config $config, string $owner, string $path, int &$rc): bool
+ {
+ $deleteLegacyFiles = $this->params->get('no-backup');
+ $override = $this->params->get('override');
+ $newConfig = $config->getConfigFile() === $path ? $config : $this->readFromIni($path, $rc);
+
+ $updated = false;
+ /** @var ConfigObject $configObject */
+ foreach ($config->getConfigObject() as $configObject) {
+ /** @var string $configOwner */
+ $configOwner = $configObject->get('owner') ?? '';
+ if ($configOwner && $configOwner !== $owner) {
+ continue;
+ }
+
+ $migrateFilter = false;
+ if ($configObject->type === 'host-action') {
+ $updated = true;
+ $migrateFilter = true;
+ $configObject->type = 'icingadb-host-action';
+ } elseif ($configObject->type === 'service-action') {
+ $updated = true;
+ $migrateFilter = true;
+ $configObject->type = 'icingadb-service-action';
+ }
+
+ /** @var ?string $urlString */
+ $urlString = $configObject->get('url');
+ if ($urlString !== null) {
+ $urlString = str_replace(
+ ['$SERVICEDESC$', '$HOSTNAME$', '$HOSTADDRESS$', '$HOSTADDRESS6$'],
+ ['$service.name$', '$host.name$', '$host.address$', '$host.address6$'],
+ $urlString
+ );
+ if ($urlString !== $configObject->url) {
+ $configObject->url = $urlString;
+ $updated = true;
+ }
+
+ $url = Url::fromPath($urlString, [], new Request());
+
+ try {
+ $urlString = UrlMigrator::transformUrl($url)->getRelativeUrl();
+ $configObject->url = $urlString;
+ $updated = true;
+ } catch (\InvalidArgumentException $err) {
+ // Do nothing
+ }
+ }
+
+ /** @var ?string $legacyFilter */
+ $legacyFilter = $configObject->get('filter');
+ if ($migrateFilter && $legacyFilter) {
+ $updated = true;
+ $filter = QueryString::parse($legacyFilter);
+ $filter = UrlMigrator::transformFilter($filter);
+ if ($filter !== false) {
+ $configObject->filter = QueryString::render($filter);
+ } else {
+ unset($configObject->filter);
+ }
+ }
+
+ $section = $config->key();
+ if (! $newConfig->hasSection($section) || $newConfig === $config || $override) {
+ $newConfig->setSection($section, $configObject);
+ }
+ }
+
+ if ($updated) {
+ try {
+ $newConfig->saveIni();
+
+ // Remove the legacy file only if explicitly requested
+ if ($deleteLegacyFiles && $newConfig !== $config) {
+ unlink($config->getConfigFile());
+ }
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ $rc = 256;
+
+ return false;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Get the navigation items config from the given ini path
+ *
+ * @param string $path Absolute path of the ini file
+ * @param int $rc The return code used to exit the action
+ *
+ * @return Config
+ */
+ private function readFromIni($path, &$rc)
+ {
+ try {
+ $config = Config::fromIni($path);
+ } catch (NotReadableError $error) {
+ Logger::error($error);
+
+ $config = new Config();
+ $rc = 128;
+ }
+
+ return $config;
+ }
+
+ private function createBackupIni(string $path, Config $config = null): void
+ {
+ $counter = 0;
+ while (true) {
+ $filepath = $counter > 0
+ ? "$path.backup$counter.ini"
+ : "$path.backup.ini";
+
+ if (! file_exists($filepath)) {
+ if ($config) {
+ $config->saveIni($filepath);
+ } else {
+ copy("$path.ini", $filepath);
+ }
+
+ break;
+ } else {
+ $counter++;
+ }
+ }
+ }
+
+ /**
+ * Checks if the given role should be updated
+ *
+ * @param string[] $role
+ * @param bool $override
+ *
+ * @return bool
+ */
+ private function shouldUpdateRole(array $role, ?bool $override): bool
+ {
+ return ! (
+ isset($role['icingadb/filter/objects'])
+ || isset($role['icingadb/filter/hosts'])
+ || isset($role['icingadb/filter/services'])
+ || isset($role['icingadb/denylist/routes'])
+ || isset($role['icingadb/denylist/variables'])
+ || isset($role['icingadb/protect/variables'])
+ || (isset($role['permissions']) && str_contains($role['permissions'], 'icingadb'))
+ )
+ || $override;
+ }
+}
diff --git a/application/controllers/CommandTransportController.php b/application/controllers/CommandTransportController.php
new file mode 100644
index 0000000..fe00537
--- /dev/null
+++ b/application/controllers/CommandTransportController.php
@@ -0,0 +1,154 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Config;
+use Icinga\Data\Filter\Filter;
+use Icinga\Forms\ConfirmRemovalForm;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransportConfig;
+use Icinga\Module\Icingadb\Forms\ApiTransportForm;
+use Icinga\Module\Icingadb\Widget\ItemList\CommandTransportList;
+use Icinga\Web\Notification;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\ButtonLink;
+
+class CommandTransportController extends ConfigController
+{
+ public function init()
+ {
+ $this->assertPermission('config/modules');
+ }
+
+ public function indexAction()
+ {
+ $list = new CommandTransportList((new CommandTransportConfig())->select());
+
+ $this->addControl(
+ (new ButtonLink(
+ t('Create Command Transport'),
+ 'icingadb/command-transport/add',
+ 'plus'
+ ))->setBaseTarget('_next')
+ );
+
+ $this->addContent($list);
+
+ $this->mergeTabs($this->Module()->getConfigTabs());
+ $this->getTabs()->disableLegacyExtensions();
+ $this->setTitle($this->getTabs()
+ ->activate('command-transports')
+ ->getActiveTab()
+ ->getLabel());
+ }
+
+ public function showAction()
+ {
+ $transportName = $this->params->getRequired('name');
+
+ $transportConfig = (new CommandTransportConfig())
+ ->select()
+ ->where('name', $transportName)
+ ->fetchRow();
+ if ($transportConfig === false) {
+ $this->httpNotFound(t('Unknown transport'));
+ }
+
+ $form = new ApiTransportForm();
+ $form->populate((array) $transportConfig);
+ $form->on(ApiTransportForm::ON_SUCCESS, function (ApiTransportForm $form) use ($transportName) {
+ (new CommandTransportConfig())->update(
+ 'transport',
+ $form->getValues(),
+ Filter::where('name', $transportName)
+ );
+
+ Notification::success(sprintf(t('Updated command transport "%s" successfully'), $transportName));
+
+ $this->redirectNow('icingadb/command-transport');
+ });
+
+ $form->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+
+ $this->addTitleTab($this->translate('Command Transport: %s'), $transportName);
+ $this->getTabs()->disableLegacyExtensions();
+ }
+
+ public function addAction()
+ {
+ $form = new ApiTransportForm();
+ $form->on(ApiTransportForm::ON_SUCCESS, function (ApiTransportForm $form) {
+ (new CommandTransportConfig())->insert('transport', $form->getValues());
+
+ Notification::success(t('Created command transport successfully'));
+
+ $this->redirectNow('icingadb/command-transport');
+ });
+
+ $form->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+
+ $this->addTitleTab($this->translate('Add Command Transport'));
+ $this->getTabs()->disableLegacyExtensions();
+ }
+
+ public function removeAction()
+ {
+ $transportName = $this->params->getRequired('name');
+
+ $form = new ConfirmRemovalForm();
+ $form->setOnSuccess(function () use ($transportName) {
+ (new CommandTransportConfig())->delete(
+ 'transport',
+ Filter::where('name', $transportName)
+ );
+
+ Notification::success(sprintf(t('Removed command transport "%s" successfully'), $transportName));
+
+ $this->redirectNow('icingadb/command-transport');
+ });
+
+ $form->handleRequest();
+
+ $this->addContent(HtmlString::create($form->render()));
+
+ $this->setTitle($this->translate('Remove Command Transport: %s'), $transportName);
+ $this->getTabs()->disableLegacyExtensions();
+ }
+
+ public function sortAction()
+ {
+ $transportName = $this->params->getRequired('name');
+ $newPosition = (int) $this->params->getRequired('pos');
+
+ $config = $this->Config('commandtransports');
+ if (! $config->hasSection($transportName)) {
+ $this->httpNotFound(t('Unknown transport'));
+ }
+
+ if ($newPosition < 0 || $newPosition > $config->count()) {
+ $this->httpBadRequest(t('Position out of bounds'));
+ }
+
+ $transports = $config->getConfigObject()->toArray();
+ $transportNames = array_keys($transports);
+
+ array_splice($transportNames, array_search($transportName, $transportNames, true), 1);
+ array_splice($transportNames, $newPosition, 0, [$transportName]);
+
+ $sortedTransports = [];
+ foreach ($transportNames as $name) {
+ $sortedTransports[$name] = $transports[$name];
+ }
+
+ $newConfig = Config::fromArray($sortedTransports);
+ $newConfig->saveIni($config->getConfigFile());
+
+ $this->redirectNow('icingadb/command-transport');
+ }
+}
diff --git a/application/controllers/CommentController.php b/application/controllers/CommentController.php
new file mode 100644
index 0000000..b184d6b
--- /dev/null
+++ b/application/controllers/CommentController.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\CommentDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+
+class CommentController extends Controller
+{
+ use CommandActions;
+
+ /** @var Comment The comment object */
+ protected $comment;
+
+ public function init()
+ {
+ $this->addTitleTab(t('Comment'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Comment::on($this->getDb())->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'service.host',
+ 'service.host.state'
+ ]);
+ $query->filter(Filter::equal('comment.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $comment = $query->first();
+ if ($comment === null) {
+ throw new NotFoundError(t('Comment not found'));
+ }
+
+ $this->comment = $comment;
+ }
+
+ public function indexAction()
+ {
+ $this->addControl((new CommentList([$this->comment]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setCaptionDisabled()
+ ->setNoSubjectLink());
+
+ $this->addContent((new CommentDetail($this->comment))->setTicketLinkEnabled());
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->comment];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::comment($this->comment);
+ }
+}
diff --git a/application/controllers/CommentsController.php b/application/controllers/CommentsController.php
new file mode 100644
index 0000000..2358423
--- /dev/null
+++ b/application/controllers/CommentsController.php
@@ -0,0 +1,197 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteCommentForm;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class CommentsController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Comments'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $comments = Comment::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($comments);
+ $sortControl = $this->createSortControl(
+ $comments,
+ [
+ 'comment.entry_time desc' => t('Entry Time'),
+ 'host.display_name' => t('Host'),
+ 'service.display_name' => t('Service'),
+ 'comment.author' => t('Author'),
+ 'comment.expire_time desc' => t('Expire Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $searchBar = $this->createSearchBar($comments, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($comments, $filter);
+
+ $comments->peekAhead($compact);
+
+ yield $this->export($comments);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::commentsDetails(), $searchBar);
+
+ $results = $comments->execute();
+
+ $this->addContent((new CommentList($results))->setViewMode($viewModeSwitcher->getViewMode()));
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d comments'),
+ $comments->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function deleteAction()
+ {
+ $this->setTitle(t('Remove Comments'));
+
+ $db = $this->getDb();
+
+ $comments = Comment::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $this->filter($comments);
+
+ $form = (new DeleteCommentForm())
+ ->setObjects($comments)
+ ->setRedirectUrl(Links::comments()->getAbsoluteUrl())
+ ->on(DeleteCommentForm::ON_SUCCESS, function ($form) {
+ // This forces the column to reload nearly instantly after the redirect
+ // and ensures the effect of the command is visible to the user asap
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow($form->getRedirectUrl());
+ })
+ ->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Comments'));
+
+ $db = $this->getDb();
+
+ $comments = Comment::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $comments->limit(3)->peekAhead();
+
+ $this->filter($comments);
+
+ yield $this->export($comments);
+
+ $rs = $comments->execute();
+
+ $this->addControl((new CommentList($rs))->setViewMode('minimal'));
+
+ $this->addControl(new ShowMore(
+ $rs,
+ Links::comments()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d comments'), $comments->count())
+ ));
+
+ $this->addContent(
+ (new DeleteCommentForm())
+ ->setObjects($comments)
+ ->setAction(
+ Links::commentsDelete()
+ ->setFilter($this->getFilter())
+ ->getAbsoluteUrl()
+ )
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Comment::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Comment::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php
new file mode 100644
index 0000000..182b7b6
--- /dev/null
+++ b/application/controllers/ConfigController.php
@@ -0,0 +1,63 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Forms\DatabaseConfigForm;
+use Icinga\Module\Icingadb\Forms\RedisConfigForm;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Web\Form;
+use Icinga\Web\Widget\Tab;
+use Icinga\Web\Widget\Tabs;
+use ipl\Html\HtmlString;
+
+class ConfigController extends Controller
+{
+ public function init()
+ {
+ $this->assertPermission('config/modules');
+
+ parent::init();
+ }
+
+ public function databaseAction()
+ {
+ $form = (new DatabaseConfigForm())
+ ->setIniConfig(Config::module('icingadb'));
+
+ $form->handleRequest();
+
+ $this->mergeTabs($this->Module()->getConfigTabs()->activate('database'));
+
+ $this->addFormToContent($form);
+ }
+
+ public function redisAction()
+ {
+ $form = (new RedisConfigForm())
+ ->setIniConfig($this->Config());
+
+ $form->handleRequest();
+
+ $this->mergeTabs($this->Module()->getConfigTabs()->activate('redis'));
+
+ $this->addFormToContent($form);
+ }
+
+ protected function addFormToContent(Form $form)
+ {
+ $this->addContent(new HtmlString($form->render()));
+ }
+
+ protected function mergeTabs(Tabs $tabs): self
+ {
+ /** @var Tab $tab */
+ foreach ($tabs->getTabs() as $tab) {
+ $this->tabs->add($tab->getName(), $tab);
+ }
+
+ return $this;
+ }
+}
diff --git a/application/controllers/DowntimeController.php b/application/controllers/DowntimeController.php
new file mode 100644
index 0000000..a0a7fa0
--- /dev/null
+++ b/application/controllers/DowntimeController.php
@@ -0,0 +1,84 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\DowntimeDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+
+class DowntimeController extends Controller
+{
+ use CommandActions;
+
+ /** @var Downtime */
+ protected $downtime;
+
+ public function init()
+ {
+ $this->addTitleTab(t('Downtime'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Downtime::on($this->getDb())->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'service.host',
+ 'service.host.state',
+ 'parent',
+ 'parent.host',
+ 'parent.host.state',
+ 'parent.service',
+ 'parent.service.state',
+ 'triggered_by',
+ 'triggered_by.host',
+ 'triggered_by.host.state',
+ 'triggered_by.service',
+ 'triggered_by.service.state'
+ ]);
+ $query->filter(Filter::equal('downtime.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $downtime = $query->first();
+ if ($downtime === null) {
+ throw new NotFoundError(t('Downtime not found'));
+ }
+
+ $this->downtime = $downtime;
+ }
+
+ public function indexAction()
+ {
+ $detail = new DowntimeDetail($this->downtime);
+
+ $this->addControl((new DowntimeList([$this->downtime]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setCaptionDisabled()
+ ->setNoSubjectLink());
+
+ $this->addContent($detail);
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->downtime];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::downtime($this->downtime);
+ }
+}
diff --git a/application/controllers/DowntimesController.php b/application/controllers/DowntimesController.php
new file mode 100644
index 0000000..c045ffb
--- /dev/null
+++ b/application/controllers/DowntimesController.php
@@ -0,0 +1,203 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class DowntimesController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Downtimes'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $downtimes = Downtime::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($downtimes);
+ $sortControl = $this->createSortControl(
+ $downtimes,
+ [
+ 'downtime.is_in_effect desc, downtime.start_time desc' => t('Is In Effect'),
+ 'downtime.entry_time' => t('Entry Time'),
+ 'host.display_name' => t('Host'),
+ 'service.display_name' => t('Service'),
+ 'downtime.author' => t('Author'),
+ 'downtime.start_time desc' => t('Start Time'),
+ 'downtime.end_time desc' => t('End Time'),
+ 'downtime.scheduled_start_time desc' => t('Scheduled Start Time'),
+ 'downtime.scheduled_end_time desc' => t('Scheduled End Time'),
+ 'downtime.duration desc' => t('Duration')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $searchBar = $this->createSearchBar($downtimes, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($downtimes, $filter);
+
+ $downtimes->peekAhead($compact);
+
+ yield $this->export($downtimes);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::downtimesDetails(), $searchBar);
+
+ $results = $downtimes->execute();
+
+ $this->addContent((new DowntimeList($results))->setViewMode($viewModeSwitcher->getViewMode()));
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d downtimes'),
+ $downtimes->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function deleteAction()
+ {
+ $this->setTitle(t('Cancel Downtimes'));
+
+ $db = $this->getDb();
+
+ $downtimes = Downtime::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $this->filter($downtimes);
+
+ $form = (new DeleteDowntimeForm())
+ ->setObjects($downtimes)
+ ->setRedirectUrl(Links::downtimes()->getAbsoluteUrl())
+ ->on(DeleteDowntimeForm::ON_SUCCESS, function ($form) {
+ // This forces the column to reload nearly instantly after the redirect
+ // and ensures the effect of the command is visible to the user asap
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow($form->getRedirectUrl());
+ })
+ ->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Downtimes'));
+
+ $db = $this->getDb();
+
+ $downtimes = Downtime::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $downtimes->limit(3)->peekAhead();
+
+ $this->filter($downtimes);
+
+ yield $this->export($downtimes);
+
+ $rs = $downtimes->execute();
+
+ $this->addControl((new DowntimeList($rs))->setViewMode('minimal'));
+
+ $this->addControl(new ShowMore(
+ $rs,
+ Links::downtimes()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d downtimes'), $downtimes->count())
+ ));
+
+ $this->addContent(
+ (new DeleteDowntimeForm())
+ ->setObjects($downtimes)
+ ->setAction(
+ Links::downtimesDelete()
+ ->setFilter($this->getFilter())
+ ->getAbsoluteUrl()
+ )
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Downtime::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Downtime::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ErrorController.php b/application/controllers/ErrorController.php
new file mode 100644
index 0000000..38621c0
--- /dev/null
+++ b/application/controllers/ErrorController.php
@@ -0,0 +1,97 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Controllers\ErrorController as IcingaErrorController;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Web\Layout\Content;
+use ipl\Web\Layout\Controls;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\Tabs;
+
+class ErrorController extends IcingaErrorController
+{
+ /** @var HtmlDocument */
+ protected $document;
+
+ /** @var Controls */
+ protected $controls;
+
+ /** @var Content */
+ protected $content;
+
+ /** @var Tabs */
+ protected $tabs;
+
+ protected function prepareInit()
+ {
+ $this->document = new HtmlDocument();
+ $this->document->setSeparator("\n");
+ $this->controls = new Controls();
+ $this->content = new Content();
+ $this->tabs = new Tabs();
+
+ $this->controls->setTabs($this->tabs);
+ $this->view->document = $this->document;
+ }
+
+ public function postDispatch()
+ {
+ $this->tabs->add(uniqid(), [
+ 'active' => true,
+ 'label' => $this->view->title,
+ 'url' => $this->getRequest()->getUrl()
+ ]);
+
+ if (! $this->content->isEmpty()) {
+ $this->document->prepend($this->content);
+ }
+
+ if (! $this->view->compact && ! $this->controls->isEmpty()) {
+ $this->document->prepend($this->controls);
+ }
+
+ parent::postDispatch();
+ }
+
+ protected function postDispatchXhr()
+ {
+ parent::postDispatchXhr();
+ $this->getResponse()->setHeader('X-Icinga-Module', $this->getModuleName(), true);
+ }
+
+ public function errorAction()
+ {
+ $error = $this->getParam('error_handler');
+ $exception = $error->exception;
+ /** @var \Exception $exception */
+
+ $message = $exception->getMessage();
+ if (substr($message, 0, 27) !== 'Cannot load resource config') {
+ $this->forward('error', 'error', 'default');
+ return;
+ } else {
+ $this->setParam('error_handler', null);
+ }
+
+ // TODO: Find a native way for ipl-html to support enriching text with html
+ $heading = Html::tag('h2', t('Database not configured'));
+ $intro = Html::tag('p', ['data-base-target' => '_next'], Html::sprintf(
+ 'You seem to not have configured a resource for Icinga DB yet. Please %s and then tell Icinga DB Web %s.',
+ new Link(
+ Html::tag('strong', 'create one'),
+ Url::fromPath('config/resource')
+ ),
+ new Link(
+ Html::tag('strong', 'which one it is'),
+ Url::fromPath('icingadb/config/database')
+ )
+ ));
+
+ $this->content->add([$heading, $intro]);
+ }
+}
diff --git a/application/controllers/EventController.php b/application/controllers/EventController.php
new file mode 100644
index 0000000..7108606
--- /dev/null
+++ b/application/controllers/EventController.php
@@ -0,0 +1,71 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use ArrayObject;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\EventDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+
+class EventController extends Controller
+{
+ /** @var History */
+ protected $event;
+
+ public function init()
+ {
+ $this->addTitleTab(t('Event'));
+
+ $id = $this->params->getRequired('id');
+
+ $query = History::on($this->getDb())
+ ->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'comment',
+ 'downtime',
+ 'downtime.parent',
+ 'downtime.parent.host',
+ 'downtime.parent.host.state',
+ 'downtime.parent.service',
+ 'downtime.parent.service.state',
+ 'downtime.triggered_by',
+ 'downtime.triggered_by.host',
+ 'downtime.triggered_by.host.state',
+ 'downtime.triggered_by.service',
+ 'downtime.triggered_by.service.state',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ])
+ ->filter(Filter::equal('id', hex2bin($id)));
+
+ $this->applyRestrictions($query);
+
+ $event = $query->first();
+ if ($event === null) {
+ $this->httpNotFound(t('Event not found'));
+ }
+
+ $this->event = $event;
+ }
+
+ public function indexAction()
+ {
+ $this->addControl((new HistoryList(new ResultSet(new ArrayObject([$this->event]))))
+ ->setViewMode('minimal')
+ ->setPageSize(1)
+ ->setCaptionDisabled()
+ ->setNoSubjectLink()
+ ->setDetailActionsDisabled());
+ $this->addContent((new EventDetail($this->event))->setTicketLinkEnabled());
+ }
+}
diff --git a/application/controllers/HealthController.php b/application/controllers/HealthController.php
new file mode 100644
index 0000000..52ba220
--- /dev/null
+++ b/application/controllers/HealthController.php
@@ -0,0 +1,115 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Command\Instance\ToggleInstanceFeatureCommand;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Instance\ToggleInstanceFeaturesForm;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\Instance;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Health;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Html;
+use ipl\Web\Url;
+
+class HealthController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Health'));
+
+ $db = $this->getDb();
+
+ $instance = Instance::on($db)->with(['endpoint']);
+ $hoststateSummary = HoststateSummary::on($db);
+ $servicestateSummary = ServicestateSummary::on($db);
+
+ $this->applyRestrictions($hoststateSummary);
+ $this->applyRestrictions($servicestateSummary);
+
+ yield $this->export($instance, $hoststateSummary, $servicestateSummary);
+
+ $instance = $instance->first();
+
+ if ($instance === null) {
+ $this->addContent(Html::tag('p', t(
+ 'It seems that Icinga DB is not running.'
+ . ' Make sure Icinga DB is running and writing into the database.'
+ )));
+
+ return;
+ }
+
+ $hoststateSummary = $hoststateSummary->first();
+ $servicestateSummary = $servicestateSummary->first();
+
+ $this->content->addAttributes(['class' => 'monitoring-health']);
+
+ $this->addContent(new Health($instance));
+ $this->addContent(Html::tag('section', ['class' => 'check-summary'], [
+ Html::tag('div', ['class' => 'col'], [
+ Html::tag('h3', t('Host Checks')),
+ Html::tag('div', ['class' => 'col-content'], [
+ new VerticalKeyValue(
+ t('Active'),
+ $hoststateSummary->hosts_active_checks_enabled
+ ),
+ new VerticalKeyValue(
+ t('Passive'),
+ $hoststateSummary->hosts_passive_checks_enabled
+ )
+ ])
+ ]),
+ Html::tag('div', ['class' => 'col'], [
+ Html::tag('h3', t('Service Checks')),
+ Html::tag('div', ['class' => 'col-content'], [
+ new VerticalKeyValue(
+ t('Active'),
+ $servicestateSummary->services_active_checks_enabled
+ ),
+ new VerticalKeyValue(
+ t('Passive'),
+ $servicestateSummary->services_passive_checks_enabled
+ )
+ ])
+ ])
+ ]));
+
+ $featureCommands = Html::tag(
+ 'section',
+ ['class' => 'instance-commands'],
+ Html::tag('h2', t('Feature Commands'))
+ );
+ $toggleInstanceFeaturesCommandForm = new ToggleInstanceFeaturesForm([
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS =>
+ $instance->icinga2_active_host_checks_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS =>
+ $instance->icinga2_active_service_checks_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS =>
+ $instance->icinga2_event_handlers_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION =>
+ $instance->icinga2_flap_detection_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS =>
+ $instance->icinga2_notifications_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA =>
+ $instance->icinga2_performance_data_enabled
+ ]);
+ $toggleInstanceFeaturesCommandForm->setObjects([$instance]);
+ $toggleInstanceFeaturesCommandForm->on(ToggleInstanceFeaturesForm::ON_SUCCESS, function () {
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow(Url::fromPath('icingadb/health')->getAbsoluteUrl());
+ });
+ $toggleInstanceFeaturesCommandForm->handleRequest(ServerRequest::fromGlobals());
+
+ $featureCommands->add($toggleInstanceFeaturesCommandForm);
+ $this->addContent($featureCommands);
+
+ $this->setAutorefreshInterval(30);
+ }
+}
diff --git a/application/controllers/HistoryController.php b/application/controllers/HistoryController.php
new file mode 100644
index 0000000..a1b873b
--- /dev/null
+++ b/application/controllers/HistoryController.php
@@ -0,0 +1,139 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class HistoryController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('History'));
+ $compact = $this->view->compact; // TODO: Find a less-legacy way..
+
+ $preserveParams = [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ];
+
+ $db = $this->getDb();
+
+ $history = History::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'comment',
+ 'downtime',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ]);
+
+ $before = $this->params->shift('before', time());
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($history);
+ $sortControl = $this->createSortControl(
+ $history,
+ [
+ 'history.event_time desc, history.event_type desc' => t('Event Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+ $searchBar = $this->createSearchBar($history, $preserveParams);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $history->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $history->resetOffset();
+ $history->limit($page * $limitControl->getLimit());
+ }
+
+ $history->filter(Filter::lessThanOrEqual('event_time', $before));
+ $this->filter($history, $filter);
+
+ $history->getWith()['history.host']->setJoinType('LEFT');
+ $history->filter(Filter::any(
+ // Because of LEFT JOINs, make sure we'll fetch history entries only for items which still exist:
+ Filter::like('host.id', '*'),
+ Filter::like('service.id', '*')
+ ));
+
+ yield $this->export($history);
+
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $url = Url::fromRequest()
+ ->onlyWith($preserveParams)
+ ->setFilter($filter);
+
+ $historyList = (new HistoryList($history->execute()))
+ ->setPageSize($limitControl->getLimit())
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+ if ($compact) {
+ $historyList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($historyList);
+ } else {
+ $this->addContent($historyList);
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(History::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(History::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php
new file mode 100644
index 0000000..259dd33
--- /dev/null
+++ b/application/controllers/HostController.php
@@ -0,0 +1,293 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use ArrayIterator;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Command\Object\GetObjectCommand;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\TabHook\HookActions;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\HostDetail;
+use Icinga\Module\Icingadb\Widget\Detail\HostInspectionDetail;
+use Icinga\Module\Icingadb\Widget\Detail\HostMetaInfo;
+use Icinga\Module\Icingadb\Widget\Detail\QuickActions;
+use Icinga\Module\Icingadb\Widget\ItemList\HostList;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+use ipl\Web\Widget\Tabs;
+
+class HostController extends Controller
+{
+ use CommandActions;
+ use HookActions;
+
+ /** @var Host The host object */
+ protected $host;
+
+ public function init()
+ {
+ $name = $this->params->getRequired('name');
+
+ $query = Host::on($this->getDb())->with(['state', 'icon_image', 'timeperiod']);
+ $query
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('host.name', $name));
+
+ $this->applyRestrictions($query);
+
+ /** @var Host $host */
+ $host = $query->first();
+ if ($host === null) {
+ throw new NotFoundError(t('Host not found'));
+ }
+
+ $this->host = $host;
+ $this->loadTabsForObject($host);
+
+ $this->setTitleTab($this->getRequest()->getActionName());
+ $this->setTitle($host->display_name);
+ }
+
+ public function indexAction()
+ {
+ $serviceSummary = ServicestateSummary::on($this->getDb());
+ $serviceSummary->filter(Filter::equal('service.host_id', $this->host->id));
+
+ $this->applyRestrictions($serviceSummary);
+
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl(new HostMetaInfo($this->host));
+ $this->addControl(new QuickActions($this->host));
+
+ $this->addContent(new HostDetail($this->host, $serviceSummary->first()));
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function sourceAction()
+ {
+ $this->assertPermission('icingadb/object/show-source');
+
+ $apiResult = (new CommandTransport())->send(
+ (new GetObjectCommand())
+ ->setObjects(new ArrayIterator([$this->host]))
+ );
+
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addContent(new HostInspectionDetail(
+ $this->host,
+ reset($apiResult)
+ ));
+ }
+
+ public function historyAction()
+ {
+ $compact = $this->view->compact; // TODO: Find a less-legacy way..
+
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $db = $this->getDb();
+
+ $history = History::on($db)->with([
+ 'host',
+ 'host.state',
+ 'comment',
+ 'downtime',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ]);
+
+ $history->filter(Filter::all(
+ Filter::equal('history.host_id', $this->host->id),
+ Filter::unlike('history.service_id', '*')
+ ));
+
+ $before = $this->params->shift('before', time());
+ $url = Url::fromRequest()->setParams(clone $this->params);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($history);
+ $sortControl = $this->createSortControl(
+ $history,
+ [
+ 'history.event_time desc, history.event_type desc' => t('Event Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+
+ $history->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $history->limit($page * $limitControl->getLimit());
+ }
+
+ $history->filter(Filter::lessThanOrEqual('event_time', $before));
+
+ yield $this->export($history);
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+
+ $historyList = (new HistoryList($history->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setPageSize($limitControl->getLimit())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+
+ if ($compact) {
+ $historyList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($historyList);
+ } else {
+ $this->addContent($historyList);
+ }
+ }
+
+ public function servicesAction()
+ {
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'state.last_comment',
+ 'icon_image',
+ 'host',
+ 'host.state'
+ ]);
+ $services
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('host.id', $this->host->id));
+
+ $this->applyRestrictions($services);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($services);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $sortControl = $this->createSortControl(
+ $services,
+ [
+ 'service.display_name' => t('Name'),
+ 'service.state.severity desc,service.state.last_state_change desc' => t('Severity'),
+ 'service.state.soft_state' => t('Current State'),
+ 'service.state.last_state_change desc' => t('Last State Change')
+ ]
+ );
+
+ yield $this->export($services);
+
+ $serviceList = (new ServiceList($services))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+
+ $this->addContent($serviceList);
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ protected function createTabs(): Tabs
+ {
+ $tabs = $this->getTabs()
+ ->add('index', [
+ 'label' => t('Host'),
+ 'url' => Links::host($this->host)
+ ])
+ ->add('services', [
+ 'label' => t('Services'),
+ 'url' => HostLinks::services($this->host)
+ ])
+ ->add('history', [
+ 'label' => t('History'),
+ 'url' => HostLinks::history($this->host)
+ ]);
+
+ if ($this->hasPermission('icingadb/object/show-source')) {
+ $tabs->add('source', [
+ 'label' => t('Source'),
+ 'url' => Links::hostSource($this->host)
+ ]);
+ }
+
+ foreach ($this->loadAdditionalTabs() as $name => $tab) {
+ $tabs->add($name, $tab + ['urlParams' => ['name' => $this->host->name]]);
+ }
+
+ return $tabs;
+ }
+
+ protected function setTitleTab(string $name)
+ {
+ $tab = $this->createTabs()->get($name);
+
+ if ($tab !== null) {
+ $tab->setActive();
+
+ $this->setTitle($tab->getLabel());
+ }
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->host];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::host($this->host);
+ }
+
+ protected function getDefaultTabControls(): array
+ {
+ return [(new HostList([$this->host]))->setDetailActionsDisabled()->setNoSubjectLink()];
+ }
+}
diff --git a/application/controllers/HostgroupController.php b/application/controllers/HostgroupController.php
new file mode 100644
index 0000000..14fd0c1
--- /dev/null
+++ b/application/controllers/HostgroupController.php
@@ -0,0 +1,82 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Hostgroupsummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\HostList;
+use Icinga\Module\Icingadb\Widget\ItemTable\HostgroupTableRow;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+
+class HostgroupController extends Controller
+{
+ /** @var Hostgroupsummary The host group object */
+ protected $hostgroup;
+
+ public function init()
+ {
+ $this->assertRouteAccess('hostgroups');
+
+ $this->addTitleTab(t('Host Group'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Hostgroupsummary::on($this->getDb());
+
+ foreach ($query->getUnions() as $unionPart) {
+ $unionPart->filter(Filter::equal('hostgroup.name', $name));
+ }
+
+ $this->applyRestrictions($query);
+
+ $hostgroup = $query->first();
+ if ($hostgroup === null) {
+ throw new NotFoundError(t('Host group not found'));
+ }
+
+ $this->hostgroup = $hostgroup;
+ $this->setTitle($hostgroup->display_name);
+ }
+
+ public function indexAction()
+ {
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with(['state', 'state.last_comment', 'icon_image']);
+ $hosts
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('hostgroup.id', $this->hostgroup->id));
+ $this->applyRestrictions($hosts);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($hosts);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $hostList = (new HostList($hosts->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+
+ yield $this->export($hosts);
+
+ // ICINGAWEB_EXPORT_FORMAT is not set yet and $this->format is inaccessible, yeah...
+ if ($this->getRequest()->getParam('format') === 'pdf') {
+ $this->addContent(new HostgroupTableRow($this->hostgroup));
+ $this->addContent(Html::tag('h2', null, t('Hosts')));
+ } else {
+ $this->addControl(new HostgroupTableRow($this->hostgroup));
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($limitControl);
+
+ $this->addContent($hostList);
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/HostgroupsController.php b/application/controllers/HostgroupsController.php
new file mode 100644
index 0000000..700c6fd
--- /dev/null
+++ b/application/controllers/HostgroupsController.php
@@ -0,0 +1,145 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\Hostgroup;
+use Icinga\Module\Icingadb\Model\Hostgroupsummary;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\HostgroupTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class HostgroupsController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Host Groups'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $hostgroups = Hostgroupsummary::on($db);
+
+ $this->handleSearchRequest($hostgroups);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($hostgroups);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $defaultSort = null;
+ if ($viewModeSwitcher->getViewMode() === 'grid') {
+ $hostgroups->without([
+ 'services_critical_handled',
+ 'services_critical_unhandled',
+ 'services_ok',
+ 'services_pending',
+ 'services_total',
+ 'services_unknown_handled',
+ 'services_unknown_unhandled',
+ 'services_warning_handled',
+ 'services_warning_unhandled',
+ ]);
+
+ $defaultSort = ['hosts_severity DESC', 'display_name'];
+ }
+
+ $sortControl = $this->createSortControl(
+ $hostgroups,
+ [
+ 'display_name' => t('Name'),
+ 'hosts_severity desc, display_name' => t('Severity'),
+ 'hosts_total desc' => t('Total Hosts'),
+ ],
+ $defaultSort
+ );
+
+ $searchBar = $this->createSearchBar($hostgroups, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($hostgroups, $filter);
+
+ $hostgroups->peekAhead($compact);
+
+ yield $this->export($hostgroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $results = $hostgroups->execute();
+
+ $this->addContent(
+ (new HostgroupTable($results))
+ ->setBaseFilter($filter)
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ );
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d hostgroups'),
+ $hostgroups->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(30);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Hostgroup::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Hostgroupsummary::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/HostsController.php b/application/controllers/HostsController.php
new file mode 100644
index 0000000..fff7139
--- /dev/null
+++ b/application/controllers/HostsController.php
@@ -0,0 +1,242 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Util\FeatureStatus;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\MultiselectQuickActions;
+use Icinga\Module\Icingadb\Widget\Detail\ObjectsDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\HostList;
+use Icinga\Module\Icingadb\Widget\HostStatusBar;
+use Icinga\Module\Icingadb\Widget\ItemTable\HostItemTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class HostsController extends Controller
+{
+ use CommandActions;
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Hosts'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with(['state', 'icon_image', 'state.last_comment']);
+ $hosts->getWith()['host.state']->setJoinType('INNER');
+ $hosts->setResultSetClass(VolatileStateResults::class);
+
+ $this->handleSearchRequest($hosts, ['address', 'address6']);
+
+ $summary = null;
+ if (! $compact) {
+ $summary = HoststateSummary::on($db);
+ }
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($hosts);
+ $sortControl = $this->createSortControl(
+ $hosts,
+ [
+ 'host.display_name' => t('Name'),
+ 'host.state.severity desc,host.state.last_state_change desc' => t('Severity'),
+ 'host.state.soft_state' => t('Current State'),
+ 'host.state.last_state_change desc' => t('Last State Change')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $columns = $this->createColumnControl($hosts, $viewModeSwitcher);
+
+ $searchBar = $this->createSearchBar($hosts, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam(),
+ 'columns'
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $hosts->peekAhead($compact);
+
+ $this->filter($hosts, $filter);
+ if (! $compact) {
+ $this->filter($summary, $filter);
+ yield $this->export($hosts, $summary);
+ } else {
+ yield $this->export($hosts);
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::hostsDetails(), $searchBar);
+
+ $results = $hosts->execute();
+
+ if ($viewModeSwitcher->getViewMode() === 'tabular') {
+ $hostList = (new HostItemTable($results, HostItemTable::applyColumnMetaData($hosts, $columns)))
+ ->setSort($sortControl->getSort());
+ } else {
+ $hostList = (new HostList($results))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+ }
+
+ $this->addContent($hostList);
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d hosts'),
+ $hosts->count()
+ ))
+ );
+ } else {
+ /** @var HoststateSummary $hostsSummary */
+ $hostsSummary = $summary->first();
+ $this->addFooter((new HostStatusBar($hostsSummary))->setBaseFilter($filter));
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Hosts'));
+
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with(['state', 'icon_image']);
+ $hosts->setResultSetClass(VolatileStateResults::class);
+ $summary = HoststateSummary::on($db)->with(['state']);
+
+ $this->filter($hosts);
+ $this->filter($summary);
+
+ $hosts->limit(3);
+ $hosts->peekAhead();
+
+ yield $this->export($hosts, $summary);
+
+ $results = $hosts->execute();
+ $summary = $summary->first();
+
+ $downtimes = Host::on($db)->with(['downtime']);
+ $downtimes->getWith()['host.downtime']->setJoinType('INNER');
+ $this->filter($downtimes);
+ $summary->downtimes_total = $downtimes->count();
+
+ $comments = Host::on($db)->with(['comment']);
+ $comments->getWith()['host.comment']->setJoinType('INNER');
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $comments->filter(Filter::equal('comment.object_type', 'host'));
+ $this->filter($comments);
+ $summary->comments_total = $comments->count();
+
+ $this->addControl(
+ (new HostList($results))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ );
+ $this->addControl(new ShowMore(
+ $results,
+ Links::hosts()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d hosts'), $hosts->count())
+ ));
+ $this->addControl(
+ (new MultiselectQuickActions('host', $summary))
+ ->setBaseFilter($this->getFilter())
+ );
+
+ $this->addContent(
+ (new ObjectsDetail('host', $summary, $hosts))
+ ->setBaseFilter($this->getFilter())
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Host::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Host::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM,
+ 'columns'
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+
+ protected function fetchCommandTargets(): Query
+ {
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with('state');
+ $hosts->setResultSetClass(VolatileStateResults::class);
+
+ switch ($this->getRequest()->getActionName()) {
+ case 'acknowledge':
+ $hosts->filter(Filter::equal('state.is_problem', 'y'))
+ ->filter(Filter::equal('state.is_acknowledged', 'n'));
+
+ break;
+ }
+
+ $this->filter($hosts);
+
+ return $hosts;
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::hostsDetails()->setFilter($this->getFilter());
+ }
+
+ protected function getFeatureStatus()
+ {
+ $summary = HoststateSummary::on($this->getDb());
+ $this->filter($summary);
+
+ return new FeatureStatus('host', $summary->first());
+ }
+}
diff --git a/application/controllers/MigrateController.php b/application/controllers/MigrateController.php
new file mode 100644
index 0000000..811b5d0
--- /dev/null
+++ b/application/controllers/MigrateController.php
@@ -0,0 +1,161 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Exception;
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Hook;
+use Icinga\Application\Icinga;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Compat\UrlMigrator;
+use Icinga\Module\Icingadb\Forms\SetAsBackendForm;
+use Icinga\Module\Icingadb\Hook\IcingadbSupportHook;
+use Icinga\Module\Icingadb\Web\Controller;
+use ipl\Html\HtmlString;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class MigrateController extends Controller
+{
+ public function monitoringUrlAction()
+ {
+ $this->assertHttpMethod('post');
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->httpBadRequest('No API request');
+ }
+
+ if (
+ ! preg_match('/([^;]*);?/', $this->getRequest()->getHeader('Content-Type'), $matches)
+ || $matches[1] !== 'application/json'
+ ) {
+ $this->httpBadRequest('No JSON content');
+ }
+
+ $urls = $this->getRequest()->getPost();
+
+ $result = [];
+ $errors = [];
+ foreach ($urls as $urlString) {
+ $url = Url::fromPath($urlString);
+ if (UrlMigrator::isSupportedUrl($url)) {
+ try {
+ $urlString = rawurldecode(UrlMigrator::transformUrl($url)->getAbsoluteUrl());
+ } catch (Exception $e) {
+ $errors[$urlString] = [
+ IcingaException::describe($e),
+ IcingaException::getConfidentialTraceAsString($e)
+ ];
+ $urlString = false;
+ }
+ }
+
+ $result[] = $urlString;
+ }
+
+ $response = $this->getResponse()->json();
+ if (empty($errors)) {
+ $response->setSuccessData($result);
+ } else {
+ $response->setFailData([
+ 'result' => $result,
+ 'errors' => $errors
+ ]);
+ }
+
+ $response->sendResponse();
+ }
+
+ public function searchUrlAction()
+ {
+ $this->assertHttpMethod('post');
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->httpBadRequest('No API request');
+ }
+
+ if (
+ ! preg_match('/([^;]*);?/', $this->getRequest()->getHeader('Content-Type'), $matches)
+ || $matches[1] !== 'application/json'
+ ) {
+ $this->httpBadRequest('No JSON content');
+ }
+
+ $urls = $this->getRequest()->getPost();
+
+ $result = [];
+ foreach ($urls as $urlString) {
+ $url = Url::fromPath($urlString);
+ $params = $url->onlyWith(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = $url->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = QueryString::parse((string) $filter);
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ $result[] = rawurldecode($url->setParams($params)->setFilter($filter)->getAbsoluteUrl());
+ }
+
+ $response = $this->getResponse()->json();
+ $response->setSuccessData($result);
+
+ $response->sendResponse();
+ }
+
+ public function checkboxStateAction()
+ {
+ $this->assertHttpMethod('get');
+
+ $form = new SetAsBackendForm();
+ $form->setAction(Url::fromPath('icingadb/migrate/checkbox-submit')->getAbsoluteUrl());
+
+ $this->getDocument()->addHtml($form);
+ }
+
+ public function checkboxSubmitAction()
+ {
+ $this->assertHttpMethod('post');
+ $this->addPart(HtmlString::create('"bogus"'), 'Behavior:Migrate');
+
+ (new SetAsBackendForm())->handleRequest(ServerRequest::fromGlobals());
+ }
+
+ public function backendSupportAction()
+ {
+ $this->assertHttpMethod('post');
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->httpBadRequest('No API request');
+ }
+
+ if (
+ ! preg_match('/([^;]*);?/', $this->getRequest()->getHeader('Content-Type'), $matches)
+ || $matches[1] !== 'application/json'
+ ) {
+ $this->httpBadRequest('No JSON content');
+ }
+
+ $moduleSupportStates = [];
+ if (
+ Icinga::app()->getModuleManager()->hasEnabled('monitoring')
+ && $this->Auth()->hasPermission('module/monitoring')
+ ) {
+ $supportList = [];
+ foreach (Hook::all('Icingadb/IcingadbSupport') as $hook) {
+ /** @var IcingadbSupportHook $hook */
+ $supportList[$hook->getModule()->getName()] = $hook->supportsIcingaDb();
+ }
+
+ $moduleSupportStates = [];
+ foreach ($this->getRequest()->getPost() as $moduleName) {
+ if (isset($supportList[$moduleName])) {
+ $moduleSupportStates[] = $supportList[$moduleName];
+ } else {
+ $moduleSupportStates[] = false;
+ }
+ }
+ }
+
+ $this->getResponse()
+ ->json()
+ ->setSuccessData($moduleSupportStates)
+ ->sendResponse();
+ }
+}
diff --git a/application/controllers/NotificationsController.php b/application/controllers/NotificationsController.php
new file mode 100644
index 0000000..2d23604
--- /dev/null
+++ b/application/controllers/NotificationsController.php
@@ -0,0 +1,136 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\NotificationList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Sql\Sql;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class NotificationsController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Notifications'));
+ $compact = $this->view->compact;
+
+ $preserveParams = [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ];
+
+ $db = $this->getDb();
+
+ $notifications = NotificationHistory::on($db)->with([
+ 'history',
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state'
+ ]);
+
+ $this->handleSearchRequest($notifications);
+ $before = $this->params->shift('before', time());
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($notifications);
+ $sortControl = $this->createSortControl(
+ $notifications,
+ [
+ 'notification_history.send_time desc' => t('Send Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+ $searchBar = $this->createSearchBar($notifications, $preserveParams);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $notifications->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $notifications->resetOffset();
+ $notifications->limit($page * $limitControl->getLimit());
+ }
+
+ $notifications->filter(Filter::lessThanOrEqual('send_time', $before));
+ $this->filter($notifications, $filter);
+ $notifications->filter(Filter::any(
+ // Make sure we'll fetch service history entries only for services which still exist
+ Filter::unlike('service_id', '*'),
+ Filter::like('history.service.id', '*')
+ ));
+
+ yield $this->export($notifications);
+
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $url = Url::fromRequest()
+ ->onlyWith($preserveParams)
+ ->setFilter($filter);
+
+ $notificationList = (new NotificationList($notifications->execute()))
+ ->setPageSize($limitControl->getLimit())
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+
+ if ($compact) {
+ $notificationList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($notificationList);
+ } else {
+ $this->addContent($notificationList);
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(NotificationHistory::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(NotificationHistory::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ServiceController.php b/application/controllers/ServiceController.php
new file mode 100644
index 0000000..8867e91
--- /dev/null
+++ b/application/controllers/ServiceController.php
@@ -0,0 +1,245 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use ArrayIterator;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Command\Object\GetObjectCommand;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Hook\TabHook\HookActions;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\QuickActions;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceDetail;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceInspectionDetail;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceMetaInfo;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+
+class ServiceController extends Controller
+{
+ use CommandActions;
+ use HookActions;
+
+ /** @var Service The service object */
+ protected $service;
+
+ public function init()
+ {
+ $name = $this->params->getRequired('name');
+ $hostName = $this->params->getRequired('host.name');
+
+ $query = Service::on($this->getDb())->with([
+ 'state',
+ 'icon_image',
+ 'host',
+ 'host.state',
+ 'timeperiod'
+ ]);
+ $query
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::all(
+ Filter::equal('service.name', $name),
+ Filter::equal('host.name', $hostName)
+ ));
+
+ $this->applyRestrictions($query);
+
+ /** @var Service $service */
+ $service = $query->first();
+ if ($service === null) {
+ throw new NotFoundError(t('Service not found'));
+ }
+
+ $this->service = $service;
+ $this->loadTabsForObject($service);
+
+ $this->setTitleTab($this->getRequest()->getActionName());
+ $this->setTitle(
+ t('%s on %s', '<service> on <host>'),
+ $service->display_name,
+ $service->host->display_name
+ );
+ }
+
+ public function indexAction()
+ {
+ if ($this->service->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new ServiceList([$this->service]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl(new ServiceMetaInfo($this->service));
+ $this->addControl(new QuickActions($this->service));
+
+ $this->addContent(new ServiceDetail($this->service));
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function sourceAction()
+ {
+ $this->assertPermission('icingadb/object/show-source');
+
+ $apiResult = (new CommandTransport())->send(
+ (new GetObjectCommand())
+ ->setObjects(new ArrayIterator([$this->service]))
+ );
+
+ if ($this->service->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new ServiceList([$this->service]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addContent(new ServiceInspectionDetail(
+ $this->service,
+ reset($apiResult)
+ ));
+ }
+
+ public function historyAction()
+ {
+ $compact = $this->view->compact; // TODO: Find a less-legacy way..
+
+ if ($this->service->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $db = $this->getDb();
+
+ $history = History::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'comment',
+ 'downtime',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ]);
+ $history->filter(Filter::all(
+ Filter::equal('history.host_id', $this->service->host_id),
+ Filter::equal('history.service_id', $this->service->id)
+ ));
+
+ $before = $this->params->shift('before', time());
+ $url = Url::fromRequest()->setParams(clone $this->params);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($history);
+ $sortControl = $this->createSortControl(
+ $history,
+ [
+ 'history.event_time desc, history.event_type desc' => t('Event Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+
+ $history->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $history->limit($page * $limitControl->getLimit());
+ }
+
+ $history->filter(Filter::lessThanOrEqual('event_time', $before));
+
+ yield $this->export($history);
+
+ $this->addControl((new ServiceList([$this->service]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+
+ $historyList = (new HistoryList($history->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setPageSize($limitControl->getLimit())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+
+ if ($compact) {
+ $historyList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($historyList);
+ } else {
+ $this->addContent($historyList);
+ }
+ }
+
+ protected function createTabs()
+ {
+ $tabs = $this->getTabs()
+ ->add('index', [
+ 'label' => t('Service'),
+ 'url' => Links::service($this->service, $this->service->host)
+ ])
+ ->add('history', [
+ 'label' => t('History'),
+ 'url' => ServiceLinks::history($this->service, $this->service->host)
+ ]);
+
+ if ($this->hasPermission('icingadb/object/show-source')) {
+ $tabs->add('source', [
+ 'label' => t('Source'),
+ 'url' => Links::serviceSource($this->service, $this->service->host)
+ ]);
+ }
+
+ foreach ($this->loadAdditionalTabs() as $name => $tab) {
+ $tabs->add($name, $tab + ['urlParams' => [
+ 'name' => $this->service->name,
+ 'host.name' => $this->service->host->name
+ ]]);
+ }
+
+ return $tabs;
+ }
+
+ protected function setTitleTab(string $name)
+ {
+ $tab = $this->createTabs()->get($name);
+
+ if ($tab !== null) {
+ $tab->setActive();
+
+ $this->setTitle($tab->getLabel());
+ }
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->service];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::service($this->service, $this->service->host);
+ }
+
+ protected function getDefaultTabControls(): array
+ {
+ return [(new ServiceList([$this->service]))->setDetailActionsDisabled()->setNoSubjectLink()];
+ }
+}
diff --git a/application/controllers/ServicegroupController.php b/application/controllers/ServicegroupController.php
new file mode 100644
index 0000000..d6ebc19
--- /dev/null
+++ b/application/controllers/ServicegroupController.php
@@ -0,0 +1,89 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\ServicegroupSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use Icinga\Module\Icingadb\Widget\ItemTable\ServicegroupTableRow;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+
+class ServicegroupController extends Controller
+{
+ /** @var ServicegroupSummary The service group object */
+ protected $servicegroup;
+
+ public function init()
+ {
+ $this->assertRouteAccess('servicegroups');
+
+ $this->addTitleTab(t('Service Group'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = ServicegroupSummary::on($this->getDb());
+
+ foreach ($query->getUnions() as $unionPart) {
+ $unionPart->filter(Filter::equal('servicegroup.name', $name));
+ }
+
+ $this->applyRestrictions($query);
+
+ $servicegroup = $query->first();
+ if ($servicegroup === null) {
+ throw new NotFoundError(t('Service group not found'));
+ }
+
+ $this->servicegroup = $servicegroup;
+ $this->setTitle($servicegroup->display_name);
+ }
+
+ public function indexAction()
+ {
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'state.last_comment',
+ 'icon_image',
+ 'host',
+ 'host.state'
+ ]);
+ $services
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('servicegroup.id', $this->servicegroup->id));
+
+ $this->applyRestrictions($services);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($services);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $serviceList = (new ServiceList($services->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+
+ yield $this->export($services);
+
+ // ICINGAWEB_EXPORT_FORMAT is not set yet and $this->format is inaccessible, yeah...
+ if ($this->getRequest()->getParam('format') === 'pdf') {
+ $this->addContent(new ServicegroupTableRow($this->servicegroup));
+ $this->addContent(Html::tag('h2', null, t('Services')));
+ } else {
+ $this->addControl(new ServicegroupTableRow($this->servicegroup));
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($limitControl);
+
+ $this->addContent($serviceList);
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/ServicegroupsController.php b/application/controllers/ServicegroupsController.php
new file mode 100644
index 0000000..299d001
--- /dev/null
+++ b/application/controllers/ServicegroupsController.php
@@ -0,0 +1,133 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use Icinga\Module\Icingadb\Model\ServicegroupSummary;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\ServicegroupTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class ServicegroupsController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Service Groups'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $servicegroups = ServicegroupSummary::on($db);
+
+ $this->handleSearchRequest($servicegroups);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($servicegroups);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $defaultSort = null;
+ if ($viewModeSwitcher->getViewMode() === 'grid') {
+ $defaultSort = ['services_severity DESC', 'display_name'];
+ }
+
+ $sortControl = $this->createSortControl(
+ $servicegroups,
+ [
+ 'display_name' => t('Name'),
+ 'services_severity desc, display_name' => t('Severity'),
+ 'services_total desc' => t('Total Services')
+ ],
+ $defaultSort
+ );
+
+ $searchBar = $this->createSearchBar($servicegroups, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($servicegroups, $filter);
+
+ $servicegroups->peekAhead($compact);
+
+ yield $this->export($servicegroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $results = $servicegroups->execute();
+
+ $this->addContent(
+ (new ServicegroupTable($results))
+ ->setBaseFilter($filter)
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ );
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d servicegroups'),
+ $servicegroups->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(30);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Servicegroup::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(ServicegroupSummary::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php
new file mode 100644
index 0000000..c39f8b5
--- /dev/null
+++ b/application/controllers/ServicesController.php
@@ -0,0 +1,436 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Data\PivotTable;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Util\FeatureStatus;
+use Icinga\Module\Icingadb\Web\Control\ProblemToggle;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\MultiselectQuickActions;
+use Icinga\Module\Icingadb\Widget\Detail\ObjectsDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use Icinga\Module\Icingadb\Widget\ItemTable\ServiceItemTable;
+use Icinga\Module\Icingadb\Widget\ServiceStatusBar;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use Icinga\Util\Environment;
+use ipl\Html\HtmlString;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class ServicesController extends Controller
+{
+ use CommandActions;
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Services'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'state.last_comment',
+ 'host',
+ 'host.state',
+ 'icon_image'
+ ]);
+ $services->getWith()['service.state']->setJoinType('INNER');
+ $services->setResultSetClass(VolatileStateResults::class);
+
+ $this->handleSearchRequest($services);
+
+ $summary = null;
+ if (! $compact) {
+ $summary = ServicestateSummary::on($db);
+ }
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($services);
+ $sortControl = $this->createSortControl(
+ $services,
+ [
+ 'service.display_name' => t('Name'),
+ 'service.state.severity desc,service.state.last_state_change desc' => t('Severity'),
+ 'service.state.soft_state' => t('Current State'),
+ 'service.state.last_state_change desc' => t('Last State Change'),
+ 'host.display_name' => t('Host')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $columns = $this->createColumnControl($services, $viewModeSwitcher);
+
+ $searchBar = $this->createSearchBar($services, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam(),
+ 'columns'
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $services->peekAhead($compact);
+
+ $this->filter($services, $filter);
+ if (! $compact) {
+ $this->filter($summary, $filter);
+ yield $this->export($services, $summary);
+ } else {
+ yield $this->export($services);
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::servicesDetails(), $searchBar);
+
+ $results = $services->execute();
+
+ if ($viewModeSwitcher->getViewMode() === 'tabular') {
+ $serviceList = (new ServiceItemTable($results, ServiceItemTable::applyColumnMetaData($services, $columns)))
+ ->setSort($sortControl->getSort());
+ } else {
+ $serviceList = (new ServiceList($results))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+ }
+
+ $this->addContent($serviceList);
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d services'),
+ $services->count()
+ ))
+ );
+ } else {
+ /** @var ServicestateSummary $servicesSummary */
+ $servicesSummary = $summary->first();
+ $this->addFooter((new ServiceStatusBar($servicesSummary))->setBaseFilter($filter));
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Services'));
+
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'icon_image',
+ 'host',
+ 'host.state'
+ ]);
+ $services->setResultSetClass(VolatileStateResults::class);
+ $summary = ServicestateSummary::on($db)->with(['state']);
+
+ $this->filter($services);
+ $this->filter($summary);
+
+ $services->limit(3);
+ $services->peekAhead();
+
+ yield $this->export($services, $summary);
+
+ $results = $services->execute();
+ $summary = $summary->first();
+
+ $downtimes = Service::on($db)->with(['downtime']);
+ $downtimes->getWith()['service.downtime']->setJoinType('INNER');
+ $this->filter($downtimes);
+ $summary->downtimes_total = $downtimes->count();
+
+ $comments = Service::on($db)->with(['comment']);
+ $comments->getWith()['service.comment']->setJoinType('INNER');
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $comments->filter(Filter::equal('comment.object_type', 'service'));
+ $this->filter($comments);
+ $summary->comments_total = $comments->count();
+
+ $this->addControl(
+ (new ServiceList($results))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ );
+ $this->addControl(new ShowMore(
+ $results,
+ Links::services()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d services'), $services->count())
+ ));
+ $this->addControl(
+ (new MultiselectQuickActions('service', $summary))
+ ->setBaseFilter($this->getFilter())
+ );
+
+ $this->addContent(
+ (new ObjectsDetail('service', $summary, $services))
+ ->setBaseFilter($this->getFilter())
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Service::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Service::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM,
+ 'columns'
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+
+ public function gridAction()
+ {
+ Environment::raiseExecutionTime();
+
+ $db = $this->getDb();
+ $this->addTitleTab(t('Service Grid'));
+
+ $query = Service::on($db)->with([
+ 'state',
+ 'host',
+ 'host.state'
+ ]);
+ $query->setResultSetClass(VolatileStateResults::class);
+
+ $this->handleSearchRequest($query);
+
+ $this->params->shift('page'); // Handled by PivotTable internally
+ $this->params->shift('limit'); // Handled by PivotTable internally
+ $flipped = $this->params->shift('flipped', false);
+
+ $problemToggle = $this->createProblemToggle();
+ $sortControl = $this->createSortControl($query, [
+ 'service.display_name' => t('Service Name'),
+ 'host.display_name' => t('Host Name'),
+ ])->setDefault('service.display_name');
+ $searchBar = $this->createSearchBar($query, [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ $sortControl->getSortParam(),
+ 'flipped',
+ 'page',
+ 'problems'
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($query, $filter);
+
+ $this->addControl($problemToggle);
+ $this->addControl($sortControl);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::servicesDetails(), $searchBar);
+
+ $pivotFilter = $problemToggle->isChecked() ?
+ Filter::equal('service.state.is_problem', 'y') : null;
+
+ $columns = [
+ 'id',
+ 'host.id',
+ 'host_name' => 'host.name',
+ 'host_display_name' => 'host.display_name',
+ 'name' => 'service.name',
+ 'display_name' => 'service.display_name',
+ 'service.state.is_handled',
+ 'service.state.output',
+ 'service.state.soft_state'
+ ];
+
+ if ($flipped) {
+ $pivot = (new PivotTable($query, 'host_name', 'name', $columns))
+ ->setXAxisFilter($pivotFilter)
+ ->setYAxisFilter($pivotFilter ? clone $pivotFilter : null)
+ ->setXAxisHeader('host_display_name')
+ ->setYAxisHeader('display_name');
+ } else {
+ $pivot = (new PivotTable($query, 'name', 'host_name', $columns))
+ ->setXAxisFilter($pivotFilter)
+ ->setYAxisFilter($pivotFilter ? clone $pivotFilter : null)
+ ->setXAxisHeader('display_name')
+ ->setYAxisHeader('host_display_name');
+ }
+
+ $this->view->horizontalPaginator = $pivot->paginateXAxis();
+ $this->view->verticalPaginator = $pivot->paginateYAxis();
+ list($pivotData, $pivotHeader) = $pivot->toArray();
+ $this->view->pivotData = $pivotData;
+ $this->view->pivotHeader = $pivotHeader;
+
+ /** Preserve filter and params in view links (the `BaseFilter` implementation for view scripts -.-) */
+ $this->view->baseUrl = Url::fromRequest()
+ ->onlyWith([
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ $sortControl->getSortParam(),
+ 'flipped',
+ 'page',
+ 'problems'
+ ]);
+ $preservedParams = $this->view->baseUrl->getParams();
+ $this->view->baseUrl->setFilter($filter);
+
+ $searchBar->setEditorUrl(Url::fromPath(
+ "icingadb/services/grid-search-editor"
+ )->setParams($preservedParams));
+
+ $this->view->controls = $this->controls;
+
+ if ($flipped) {
+ $this->getHelper('viewRenderer')->setScriptAction('grid-flipped');
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ // TODO: Everything up to addContent() (inclusive) can be removed once the grid is a widget
+ $this->view->controls = ''; // Relevant controls are transmitted separately
+ $viewRenderer = $this->getHelper('viewRenderer');
+ $viewRenderer->postDispatch();
+ $viewRenderer->setNoRender(false);
+
+ $content = trim($this->getResponse());
+ $this->getResponse()->clearBody($viewRenderer->getResponseSegment());
+
+ $this->addContent(HtmlString::create(substr($content, strpos($content, '>') + 1, -6)));
+
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(30);
+ }
+
+ public function gridSearchEditorAction()
+ {
+ $editor = $this->createSearchEditor(
+ Service::on($this->getDb()),
+ Url::fromPath('icingadb/services/grid'),
+ [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ 'flipped',
+ 'page',
+ 'problems'
+ ]
+ );
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+
+ protected function fetchCommandTargets(): Query
+ {
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'host',
+ 'host.state'
+ ]);
+ $services->setResultSetClass(VolatileStateResults::class);
+
+ switch ($this->getRequest()->getActionName()) {
+ case 'acknowledge':
+ $services->filter(Filter::equal('state.is_problem', 'y'))
+ ->filter(Filter::equal('state.is_acknowledged', 'n'));
+
+ break;
+ }
+
+ $this->filter($services);
+
+ return $services;
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::servicesDetails()->setFilter($this->getFilter());
+ }
+
+ protected function getFeatureStatus()
+ {
+ $summary = ServicestateSummary::on($this->getDb());
+ $this->filter($summary);
+
+ return new FeatureStatus('service', $summary->first());
+ }
+
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter, array $additionalColumns)
+ {
+ if ($this->params->shift('_hostFilterOnly', false)) {
+ foreach (['host.name_ci', 'host.display_name', 'host.address', 'host.address6'] as $column) {
+ $filter->add(Filter::like($column, "*$search*"));
+ }
+ } else {
+ parent::prepareSearchFilter($query, $search, $filter, $additionalColumns);
+ }
+ }
+
+ public function createProblemToggle(): ProblemToggle
+ {
+ $filter = $this->params->shift('problems');
+
+ $problemToggle = new ProblemToggle($filter);
+ $problemToggle->setIdProtector([$this->getRequest(), 'protectId']);
+
+ $problemToggle->on(ProblemToggle::ON_SUCCESS, function (ProblemToggle $form) {
+ if (! $form->getElement('problems')->isChecked()) {
+ $this->redirectNow(Url::fromRequest()->remove('problems'));
+ } else {
+ $this->redirectNow(Url::fromRequest()->setParams($this->params->add('problems')));
+ }
+ })->handleRequest(ServerRequest::fromGlobals());
+
+ return $problemToggle;
+ }
+}
diff --git a/application/controllers/TacticalController.php b/application/controllers/TacticalController.php
new file mode 100644
index 0000000..b8d3757
--- /dev/null
+++ b/application/controllers/TacticalController.php
@@ -0,0 +1,94 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\HostSummaryDonut;
+use Icinga\Module\Icingadb\Widget\ServiceSummaryDonut;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class TacticalController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Tactical Overview'));
+
+ $db = $this->getDb();
+
+ $hoststateSummary = HoststateSummary::on($db);
+ $servicestateSummary = ServicestateSummary::on($db);
+
+ $this->handleSearchRequest($servicestateSummary, [
+ 'host.name_ci',
+ 'host.display_name',
+ 'host.address',
+ 'host.address6'
+ ]);
+
+ $searchBar = $this->createSearchBar($servicestateSummary);
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($hoststateSummary, $filter);
+ $this->filter($servicestateSummary, $filter);
+
+ yield $this->export($hoststateSummary, $servicestateSummary);
+
+ $this->addControl($searchBar);
+
+ $this->addContent(
+ (new HostSummaryDonut($hoststateSummary->first()))
+ ->setBaseFilter($filter)
+ );
+
+ $this->addContent(
+ (new ServiceSummaryDonut($servicestateSummary->first()))
+ ->setBaseFilter($filter)
+ );
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(ServicestateSummary::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(ServicestateSummary::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php
new file mode 100644
index 0000000..9321965
--- /dev/null
+++ b/application/controllers/UserController.php
@@ -0,0 +1,48 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\UserDetail;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTableRow;
+use ipl\Stdlib\Filter;
+
+class UserController extends Controller
+{
+ /** @var User The user object */
+ protected $user;
+
+ public function init()
+ {
+ $this->assertRouteAccess('users');
+
+ $this->addTitleTab(t('User'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = User::on($this->getDb());
+ $query->filter(Filter::equal('user.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $user = $query->first();
+ if ($user === null) {
+ throw new NotFoundError(t('User not found'));
+ }
+
+ $this->user = $user;
+ $this->setTitle($user->display_name);
+ }
+
+ public function indexAction()
+ {
+ $this->addControl(new UserTableRow($this->user));
+ $this->addContent(new UserDetail($this->user));
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/UsergroupController.php b/application/controllers/UsergroupController.php
new file mode 100644
index 0000000..8c3fed8
--- /dev/null
+++ b/application/controllers/UsergroupController.php
@@ -0,0 +1,48 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\UsergroupDetail;
+use Icinga\Module\Icingadb\Widget\ItemTable\UsergroupTableRow;
+use ipl\Stdlib\Filter;
+
+class UsergroupController extends Controller
+{
+ /** @var Usergroup The usergroup object */
+ protected $usergroup;
+
+ public function init()
+ {
+ $this->assertRouteAccess('usergroups');
+
+ $this->addTitleTab(t('User Group'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Usergroup::on($this->getDb());
+ $query->filter(Filter::equal('usergroup.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $usergroup = $query->first();
+ if ($usergroup === null) {
+ throw new NotFoundError(t('User group not found'));
+ }
+
+ $this->usergroup = $usergroup;
+ $this->setTitle($usergroup->display_name);
+ }
+
+ public function indexAction()
+ {
+ $this->addControl(new UsergroupTableRow($this->usergroup));
+ $this->addContent(new UsergroupDetail($this->usergroup));
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/UsergroupsController.php b/application/controllers/UsergroupsController.php
new file mode 100644
index 0000000..99a73a9
--- /dev/null
+++ b/application/controllers/UsergroupsController.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\UsergroupTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class UsergroupsController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('User Groups'));
+
+ $db = $this->getDb();
+
+ $usergroups = Usergroup::on($db);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($usergroups);
+ $sortControl = $this->createSortControl(
+ $usergroups,
+ [
+ 'usergroup.display_name' => t('Name')
+ ]
+ );
+ $searchBar = $this->createSearchBar($usergroups, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($usergroups, $filter);
+
+ yield $this->export($usergroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->addContent(new UsergroupTable($usergroups));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Usergroup::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Usergroup::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/UsersController.php b/application/controllers/UsersController.php
new file mode 100644
index 0000000..83ee96d
--- /dev/null
+++ b/application/controllers/UsersController.php
@@ -0,0 +1,97 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class UsersController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Users'));
+
+ $db = $this->getDb();
+
+ $users = User::on($db);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($users);
+ $sortControl = $this->createSortControl(
+ $users,
+ [
+ 'user.display_name' => t('Name'),
+ 'user.email' => t('Email'),
+ 'user.pager' => t('Pager Address / Number')
+ ]
+ );
+ $searchBar = $this->createSearchBar($users, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($users, $filter);
+
+ yield $this->export($users);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->addContent(new UserTable($users));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(User::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(User::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/forms/ApiTransportForm.php b/application/forms/ApiTransportForm.php
new file mode 100644
index 0000000..27c147b
--- /dev/null
+++ b/application/forms/ApiTransportForm.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransportException;
+use Icinga\Web\Session;
+use ipl\Web\Common\CsrfCounterMeasure;
+use ipl\Web\Compat\CompatForm;
+
+class ApiTransportForm extends CompatForm
+{
+ use CsrfCounterMeasure;
+
+ protected function assemble()
+ {
+ // TODO: Use a validator to check if a name is not already in use
+ $this->addElement('text', 'name', [
+ 'required' => true,
+ 'label' => t('Transport Name')
+ ]);
+
+ $this->addElement('hidden', 'transport', [
+ 'value' => 'api'
+ ]);
+
+ $this->addElement('text', 'host', [
+ 'required' => true,
+ 'id' => 'api_transport_host',
+ 'label' => t('Host'),
+ 'description' => t('Hostname or address of the Icinga master')
+ ]);
+
+ // TODO: Don't rely only on browser validation
+ $this->addElement('number', 'port', [
+ 'required' => true,
+ 'label' => t('Port'),
+ 'value' => 5665,
+ 'min' => 1,
+ 'max' => 65536
+ ]);
+
+ $this->addElement('text', 'username', [
+ 'required' => true,
+ 'label' => t('API Username'),
+ 'description' => t('User to authenticate with using HTTP Basic Auth')
+ ]);
+
+ $this->addElement('password', 'password', [
+ 'required' => true,
+ 'autocomplete' => 'new-password',
+ 'label' => t('API Password')
+ ]);
+
+ $this->addElement('submit', 'btn_submit', [
+ 'label' => t('Save')
+ ]);
+
+ $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId()));
+ }
+
+ public function validate()
+ {
+ parent::validate();
+ if (! $this->isValid) {
+ return $this;
+ }
+
+ if ($this->getPopulatedValue('force_creation') === 'y') {
+ return $this;
+ }
+
+ try {
+ CommandTransport::createTransport(new ConfigObject($this->getValues()))->probe();
+ } catch (CommandTransportException $e) {
+ $this->addMessage(
+ sprintf(t('Failed to successfully validate the configuration: %s'), $e->getMessage())
+ );
+
+ $forceCheckbox = $this->createElement(
+ 'checkbox',
+ 'force_creation',
+ [
+ 'ignore' => true,
+ 'label' => t('Force Changes'),
+ 'description' => t('Check this box to enforce changes without connectivity validation')
+ ]
+ );
+
+ $this->registerElement($forceCheckbox);
+ $this->decorate($forceCheckbox);
+ $this->prepend($forceCheckbox);
+
+ $this->isValid = false;
+ }
+
+ return $this;
+ }
+}
diff --git a/application/forms/Command/CommandForm.php b/application/forms/Command/CommandForm.php
new file mode 100644
index 0000000..a535c6d
--- /dev/null
+++ b/application/forms/Command/CommandForm.php
@@ -0,0 +1,179 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command;
+
+use ArrayIterator;
+use Exception;
+use Generator;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Web\Notification;
+use Icinga\Web\Session;
+use ipl\Html\Form;
+use ipl\Orm\Model;
+use ipl\Web\Common\CsrfCounterMeasure;
+use Traversable;
+
+abstract class CommandForm extends Form
+{
+ use Auth;
+ use CsrfCounterMeasure;
+
+ protected $defaultAttributes = ['class' => 'icinga-form icinga-controls'];
+
+ /** @var mixed */
+ protected $objects;
+
+ /** @var bool */
+ protected $isApiTarget = false;
+
+ /**
+ * Whether an error occurred while sending the command
+ *
+ * Prevents the success message from being rendered simultaneously
+ *
+ * @var bool
+ */
+ protected $errorOccurred = false;
+
+ /**
+ * Set the objects to issue the command for
+ *
+ * @param mixed $objects A traversable that is also countable
+ *
+ * @return $this
+ */
+ public function setObjects($objects): self
+ {
+ $this->objects = $objects;
+
+ return $this;
+ }
+
+ /**
+ * Get the objects to issue the command for
+ *
+ * @return mixed
+ */
+ public function getObjects()
+ {
+ return $this->objects;
+ }
+
+ /**
+ * Set whether this form is an API target
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setIsApiTarget(bool $state = true): self
+ {
+ $this->isApiTarget = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether this form is an API target
+ *
+ * @return bool
+ */
+ public function isApiTarget(): bool
+ {
+ return $this->isApiTarget;
+ }
+
+ /**
+ * Create and add form elements representing the command's options
+ *
+ * @return void
+ */
+ abstract protected function assembleElements();
+
+ /**
+ * Create and add a submit button to the form
+ *
+ * @return void
+ */
+ abstract protected function assembleSubmitButton();
+
+ /**
+ * Get the commands to issue for the given objects
+ *
+ * @param Traversable<Model> $objects
+ *
+ * @return Traversable<IcingaCommand>
+ */
+ abstract protected function getCommands(Traversable $objects): Traversable;
+
+ protected function assemble()
+ {
+ $this->assembleElements();
+
+ if (! $this->isApiTarget()) {
+ $this->assembleSubmitButton();
+ $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId()));
+ }
+ }
+
+ protected function onSuccess()
+ {
+ $errors = [];
+ $objects = $this->getObjects();
+
+ foreach ($this->getCommands(is_array($objects) ? new ArrayIterator($objects) : $objects) as $command) {
+ try {
+ $this->sendCommand($command);
+ } catch (Exception $e) {
+ Logger::error($e->getMessage());
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if (! empty($errors)) {
+ if (count($errors) > 1) {
+ Notification::warning(
+ t('Some commands were not transmitted. Please check the log. The first error follows.')
+ );
+ }
+
+ $this->errorOccurred = true;
+
+ Notification::error($errors[0]);
+ }
+ }
+
+ /**
+ * Transmit the given command
+ *
+ * @param IcingaCommand $command
+ *
+ * @return void
+ */
+ protected function sendCommand(IcingaCommand $command)
+ {
+ (new CommandTransport())->send($command);
+ }
+
+ /**
+ * Yield the $objects the currently logged in user has the permission $permission for
+ *
+ * @param string $permission
+ * @param Traversable $objects
+ *
+ * @return Generator
+ */
+ protected function filterGrantedOn(string $permission, Traversable $objects): Generator
+ {
+ foreach ($objects as $object) {
+ if ($this->isGrantedOn($permission, $object)) {
+ yield $object;
+ }
+ }
+ }
+}
diff --git a/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php b/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
new file mode 100644
index 0000000..cf14db8
--- /dev/null
+++ b/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
@@ -0,0 +1,154 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Instance;
+
+use Icinga\Module\Icingadb\Command\Instance\ToggleInstanceFeatureCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use Traversable;
+
+class ToggleInstanceFeaturesForm extends CommandForm
+{
+ protected $features;
+
+ protected $featureStatus;
+
+ /**
+ * ToggleFeature(s) being used to submit this form
+ *
+ * @var ToggleInstanceFeatureCommand[]
+ */
+ protected $submittedFeatures = [];
+
+ public function __construct(array $featureStatus)
+ {
+ $this->featureStatus = $featureStatus;
+ $this->features = [
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS =>
+ t('Active Host Checks'),
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS =>
+ t('Active Service Checks'),
+ ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS =>
+ t('Event Handlers'),
+ ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION =>
+ t('Flap Detection'),
+ ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS =>
+ t('Notifications'),
+ ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA =>
+ t('Performance Data')
+ ];
+
+ $this->getAttributes()->add('class', 'instance-features');
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ foreach ($this->submittedFeatures as $feature) {
+ $enabled = $feature->getEnabled();
+ switch ($feature->getFeature()) {
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled active host checks successfully');
+ } else {
+ $message = t('Disabled active host checks successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled active service checks successfully');
+ } else {
+ $message = t('Disabled active service checks successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS:
+ if ($enabled) {
+ $message = t('Enabled event handlers successfully');
+ } else {
+ $message = t('Disabled event handlers checks successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION:
+ if ($enabled) {
+ $message = t('Enabled flap detection successfully');
+ } else {
+ $message = t('Disabled flap detection successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS:
+ if ($enabled) {
+ $message = t('Enabled notifications successfully');
+ } else {
+ $message = t('Disabled notifications successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA:
+ if ($enabled) {
+ $message = t('Enabled performance data successfully');
+ } else {
+ $message = t('Disabled performance data successfully');
+ }
+
+ break;
+ default:
+ $message = t('Invalid feature option');
+ break;
+ }
+
+ Notification::success($message);
+ }
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $disabled = ! $this->getAuth()->hasPermission('icingadb/command/feature/instance');
+ $decorator = new IcingaFormDecorator();
+
+ foreach ($this->features as $feature => $label) {
+ $this->addElement(
+ 'checkbox',
+ $feature,
+ [
+ 'class' => 'autosubmit',
+ 'label' => $label,
+ 'disabled' => $disabled,
+ 'value' => (bool) $this->featureStatus[$feature]
+ ]
+ );
+ $decorator->decorate($this->getElement($feature));
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ foreach ($this->features as $feature => $spec) {
+ $featureState = $this->getElement($feature)->isChecked();
+
+ if ((int) $featureState === (int) $this->featureStatus[$feature]) {
+ continue;
+ }
+
+ $command = new ToggleInstanceFeatureCommand();
+ $command->setFeature($feature);
+ $command->setEnabled($featureState);
+
+ $this->submittedFeatures[] = $command;
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/AcknowledgeProblemForm.php b/application/forms/Command/Object/AcknowledgeProblemForm.php
new file mode 100644
index 0000000..81b93e2
--- /dev/null
+++ b/application/forms/Command/Object/AcknowledgeProblemForm.php
@@ -0,0 +1,210 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\AcknowledgeProblemCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class AcknowledgeProblemForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Acknowledged problem successfully',
+ 'Acknowledged problem on %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Acknowledged problem successfully',
+ 'Acknowledged problem on %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement('li', null, Text::create(t(
+ 'This command is used to acknowledge host or service problems. When a problem is acknowledged,'
+ . ' future notifications about problems are temporarily disabled until the host or service'
+ . ' recovers.'
+ )))
+ )
+ ));
+
+ $config = Config::module('icingadb');
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'If you work with other administrators, you may find it useful to share information about'
+ . ' the host or service that is having problems. Make sure you enter a brief description of'
+ . ' what you are doing.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $this->addElement(
+ 'checkbox',
+ 'persistent',
+ [
+ 'label' => t('Persistent Comment'),
+ 'value' => (bool) $config->get('settings', 'acknowledge_persistent', false),
+ 'description' => t(
+ 'If you want the comment to remain even when the acknowledgement is removed, check this'
+ . ' option.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('persistent'));
+
+ $this->addElement(
+ 'checkbox',
+ 'notify',
+ [
+ 'label' => t('Send Notification'),
+ 'value' => (bool) $config->get('settings', 'acknowledge_notify', true),
+ 'description' => t(
+ 'If you want an acknowledgement notification to be sent out to the appropriate contacts,'
+ . ' check this option.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('notify'));
+
+ $this->addElement(
+ 'checkbox',
+ 'sticky',
+ [
+ 'label' => t('Sticky Acknowledgement'),
+ 'value' => (bool) $config->get('settings', 'acknowledge_sticky', false),
+ 'description' => t(
+ 'If you want the acknowledgement to remain until the host or service recovers even if the host'
+ . ' or service changes state, check this option.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('sticky'));
+
+ $this->addElement(
+ 'checkbox',
+ 'expire',
+ [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => (bool) $config->get('settings', 'acknowledge_expire', false),
+ 'label' => t('Use Expire Time'),
+ 'description' => t('If the acknowledgement should expire, check this option.')
+ ]
+ );
+ $decorator->decorate($this->getElement('expire'));
+
+ if ($this->getElement('expire')->isChecked()) {
+ $expireTime = new DateTime();
+ $expireTime->add(new DateInterval($config->get('settings', 'acknowledge_expire_time', 'PT1H')));
+
+ $this->addElement(
+ 'localDateTime',
+ 'expire_time',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'value' => $expireTime,
+ 'label' => t('Expire Time'),
+ 'description' => t(
+ 'Choose the date and time when Icinga should delete the acknowledgement.'
+ ),
+ 'validators' => [
+ 'DateTime' => ['break_chain_on_failure' => true],
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+ if ($value <= (new DateTime())) {
+ $validator->addMessage(t('The expire time must not be in the past'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('expire_time'));
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Acknowledge problem', 'Acknowledge problems', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/acknowledge-problem', $objects);
+
+ if ($granted->valid()) {
+ $command = new AcknowledgeProblemCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+ $command->setNotify($this->getElement('notify')->isChecked());
+ $command->setSticky($this->getElement('sticky')->isChecked());
+ $command->setPersistent($this->getElement('persistent')->isChecked());
+
+ if (($expireTime = $this->getValue('expire_time')) !== null) {
+ /** @var DateTime $expireTime */
+ $command->setExpireTime($expireTime->getTimestamp());
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/AddCommentForm.php b/application/forms/Command/Object/AddCommentForm.php
new file mode 100644
index 0000000..9cd0754
--- /dev/null
+++ b/application/forms/Command/Object/AddCommentForm.php
@@ -0,0 +1,162 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\AddCommentCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class AddCommentForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(
+ tp('Added comment successfully', 'Added comment to %d hosts successfully', $countObjects),
+ $countObjects
+ );
+ } else {
+ $message = sprintf(
+ tp('Added comment successfully', 'Added comment to %d services successfully', $countObjects),
+ $countObjects
+ );
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t('This command is used to add host or service comments.'))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'If you work with other administrators, you may find it useful to share information about'
+ . ' the host or service that is having problems. Make sure you enter a brief description of'
+ . ' what you are doing.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $config = Config::module('icingadb');
+
+ $this->addElement(
+ 'checkbox',
+ 'expire',
+ [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => (bool) $config->get('settings', 'comment_expire', false),
+ 'label' => t('Use Expire Time'),
+ 'description' => t('If the comment should expire, check this option.')
+ ]
+ );
+ $decorator->decorate($this->getElement('expire'));
+
+ if ($this->getElement('expire')->isChecked()) {
+ $expireTime = new DateTime();
+ $expireTime->add(new DateInterval($config->get('settings', 'comment_expire_time', 'PT1H')));
+
+ $this->addElement(
+ 'localDateTime',
+ 'expire_time',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'value' => $expireTime,
+ 'label' => t('Expire Time'),
+ 'description' => t('Choose the date and time when Icinga should delete the comment.'),
+ 'validators' => [
+ 'DateTime' => ['break_chain_on_failure' => true],
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+ if ($value <= (new DateTime())) {
+ $validator->addMessage(t('The expire time must not be in the past'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('expire_time'));
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Add comment', 'Add comments', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/comment/add', $objects);
+
+ if ($granted->valid()) {
+ $command = new AddCommentCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ if (($expireTime = $this->getValue('expire_time'))) {
+ /** @var DateTime $expireTime */
+ $command->setExpireTime($expireTime->getTimestamp());
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/CheckNowForm.php b/application/forms/Command/Object/CheckNowForm.php
new file mode 100644
index 0000000..b7a506c
--- /dev/null
+++ b/application/forms/Command/Object/CheckNowForm.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class CheckNowForm extends CommandForm
+{
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if (! $this->errorOccurred) {
+ Notification::success(tp('Scheduling check..', 'Scheduling checks..', count($this->getObjects())));
+ }
+ });
+ }
+
+ protected function assembleElements()
+ {
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['link-button', 'spinner'],
+ 'label' => [
+ new Icon('sync-alt'),
+ t('Check Now')
+ ],
+ 'title' => t('Schedule the next active check to run immediately')
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $object)
+ || (
+ $object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $object)
+ )
+ ) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new ScheduleCheckCommand();
+ $command->setObjects($granted);
+ $command->setCheckTime(time());
+ $command->setForced();
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/DeleteCommentForm.php b/application/forms/Command/Object/DeleteCommentForm.php
new file mode 100644
index 0000000..25275ba
--- /dev/null
+++ b/application/forms/Command/Object/DeleteCommentForm.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\DeleteCommentCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\Common\RedirectOption;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class DeleteCommentForm extends CommandForm
+{
+ use RedirectOption;
+
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Removed comment successfully', 'Removed comment from %d objects successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addElement($this->createRedirectOption());
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['cancel-button', 'spinner'],
+ 'label' => [
+ new Icon('trash'),
+ tp('Remove Comment', 'Remove Comments', count($this->getObjects()))
+ ]
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if ($this->isGrantedOn('icingadb/command/comment/delete', $object->{$object->object_type})) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new DeleteCommentCommand();
+ $command->setObjects($granted);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/DeleteDowntimeForm.php b/application/forms/Command/Object/DeleteDowntimeForm.php
new file mode 100644
index 0000000..5f695b9
--- /dev/null
+++ b/application/forms/Command/Object/DeleteDowntimeForm.php
@@ -0,0 +1,90 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\DeleteDowntimeCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\Common\RedirectOption;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class DeleteDowntimeForm extends CommandForm
+{
+ use RedirectOption;
+
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Removed downtime successfully', 'Removed downtime from %d objects successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addElement($this->createRedirectOption());
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $isDisabled = true;
+ foreach ($this->getObjects() as $downtime) {
+ if ($downtime->scheduled_by === null) {
+ $isDisabled = false;
+ break;
+ }
+ }
+
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['cancel-button', 'spinner'],
+ 'disabled' => $isDisabled ?: null,
+ 'title' => $isDisabled
+ ? t('Downtime cannot be removed at runtime because it is based on a configured scheduled downtime.')
+ : null,
+ 'label' => [
+ new Icon('trash'),
+ tp('Delete downtime', 'Delete downtimes', count($this->getObjects()))
+ ]
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if (
+ $this->isGrantedOn('icingadb/command/downtime/delete', $object->{$object->object_type})
+ && $object->scheduled_by === null
+ ) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new DeleteDowntimeCommand();
+ $command->setObjects($granted);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ProcessCheckResultForm.php b/application/forms/Command/Object/ProcessCheckResultForm.php
new file mode 100644
index 0000000..5764bf8
--- /dev/null
+++ b/application/forms/Command/Object/ProcessCheckResultForm.php
@@ -0,0 +1,156 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\ProcessCheckResultCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class ProcessCheckResultForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Submitted passive check result successfully',
+ 'Submitted passive check result for %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Submitted passive check result successfully',
+ 'Submitted passive check result for %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t('This command is used to submit passive host or service check results.'))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ /** @var Model $object */
+ $object = iterable_value_first($this->getObjects());
+
+ $this->addElement(
+ 'select',
+ 'status',
+ [
+ 'required' => true,
+ 'label' => t('Status'),
+ 'description' => t('The state this check result should report'),
+ 'options' => $object instanceof Host ? [
+ ProcessCheckResultCommand::HOST_UP => t('UP', 'icinga.state'),
+ ProcessCheckResultCommand::HOST_DOWN => t('DOWN', 'icinga.state')
+ ] : [
+ ProcessCheckResultCommand::SERVICE_OK => t('OK', 'icinga.state'),
+ ProcessCheckResultCommand::SERVICE_WARNING => t('WARNING', 'icinga.state'),
+ ProcessCheckResultCommand::SERVICE_CRITICAL => t('CRITICAL', 'icinga.state'),
+ ProcessCheckResultCommand::SERVICE_UNKNOWN => t('UNKNOWN', 'icinga.state')
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('status'));
+
+ $this->addElement(
+ 'text',
+ 'output',
+ [
+ 'required' => true,
+ 'label' => t('Output'),
+ 'description' => t('The plugin output of this check result')
+ ]
+ );
+ $decorator->decorate($this->getElement('output'));
+
+ $this->addElement(
+ 'text',
+ 'perfdata',
+ [
+ 'allowEmpty' => true,
+ 'label' => t('Performance Data'),
+ 'description' => t(
+ 'The performance data of this check result. Leave empty'
+ . ' if this check result has no performance data'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('perfdata'));
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp(
+ 'Submit Passive Check Result',
+ 'Submit Passive Check Results',
+ count($this->getObjects())
+ )
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($this->filterGrantedOn('icingadb/command/process-check-result', $objects) as $object) {
+ if ($object->passive_checks_enabled) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new ProcessCheckResultCommand();
+ $command->setObjects($granted);
+ $command->setStatus($this->getValue('status'));
+ $command->setOutput($this->getValue('output'));
+ $command->setPerformanceData($this->getValue('perfdata'));
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/RemoveAcknowledgementForm.php b/application/forms/Command/Object/RemoveAcknowledgementForm.php
new file mode 100644
index 0000000..8697985
--- /dev/null
+++ b/application/forms/Command/Object/RemoveAcknowledgementForm.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\RemoveAcknowledgementCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class RemoveAcknowledgementForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Removed acknowledgment successfully',
+ 'Removed acknowledgment from %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Removed acknowledgment successfully',
+ 'Removed acknowledgment from %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ protected function assembleElements()
+ {
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['link-button', 'spinner'],
+ 'label' => [
+ new Icon('trash'),
+ tp('Remove acknowledgement', 'Remove acknowledgements', count($this->getObjects()))
+ ]
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/remove-acknowledgement', $objects);
+
+ if ($granted->valid()) {
+ $command = new RemoveAcknowledgementCommand();
+ $command->setObjects($granted);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleCheckForm.php b/application/forms/Command/Object/ScheduleCheckForm.php
new file mode 100644
index 0000000..9b32ea1
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleCheckForm.php
@@ -0,0 +1,137 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class ScheduleCheckForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(
+ tp('Scheduled check successfully', 'Scheduled check for %d hosts successfully', $countObjects),
+ $countObjects
+ );
+ } else {
+ $message = sprintf(
+ tp('Scheduled check successfully', 'Scheduled check for %d services successfully', $countObjects),
+ $countObjects
+ );
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t(
+ 'This command is used to schedule the next check of hosts or services. Icinga'
+ . ' will re-queue the hosts or services to be checked at the time you specify.'
+ ))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'localDateTime',
+ 'check_time',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'label' => t('Check Time'),
+ 'description' => t('Set the date and time when the check should be scheduled.'),
+ 'value' => (new DateTime())->add(new DateInterval('PT1H'))
+ ]
+ );
+ $decorator->decorate($this->getElement('check_time'));
+
+ $this->addElement(
+ 'checkbox',
+ 'force_check',
+ [
+ 'label' => t('Force Check'),
+ 'description' => t(
+ 'If you select this option, Icinga will force a check regardless of both what time the'
+ . ' scheduled check occurs and whether or not checks are enabled.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('force_check'));
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Schedule check', 'Schedule checks', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $object)
+ || (
+ $object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $object)
+ )
+ ) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new ScheduleCheckCommand();
+ $command->setObjects($granted);
+ $command->setForced($this->getElement('force_check')->isChecked());
+ $command->setCheckTime($this->getValue('check_time')->getTimestamp());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleHostDowntimeForm.php b/application/forms/Command/Object/ScheduleHostDowntimeForm.php
new file mode 100644
index 0000000..bc21114
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleHostDowntimeForm.php
@@ -0,0 +1,119 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\PropagateHostDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleHostDowntimeCommand;
+use Icinga\Web\Notification;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use Traversable;
+
+class ScheduleHostDowntimeForm extends ScheduleServiceDowntimeForm
+{
+ /** @var bool */
+ protected $hostDowntimeAllServices;
+
+ public function __construct()
+ {
+ $this->start = new DateTime();
+ $config = Config::module('icingadb');
+ $this->commentText = $config->get('settings', 'hostdowntime_comment_text');
+
+ $this->hostDowntimeAllServices = (bool) $config->get('settings', 'hostdowntime_all_services', false);
+
+ $fixedEnd = clone $this->start;
+ $fixed = $config->get('settings', 'hostdowntime_end_fixed', 'PT1H');
+ $this->fixedEnd = $fixedEnd->add(new DateInterval($fixed));
+
+ $flexibleEnd = clone $this->start;
+ $flexible = $config->get('settings', 'hostdowntime_end_flexible', 'PT2H');
+ $this->flexibleEnd = $flexibleEnd->add(new DateInterval($flexible));
+
+ $flexibleDuration = $config->get('settings', 'hostdowntime_flexible_duration', 'PT2H');
+ $this->flexibleDuration = new DateInterval($flexibleDuration);
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Scheduled downtime successfully', 'Scheduled downtime for %d hosts successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ parent::assembleElements();
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'checkbox',
+ 'all_services',
+ [
+ 'label' => t('All Services'),
+ 'description' => t(
+ 'Sets downtime for all services for the matched host objects. If child options are set,'
+ . ' all child hosts and their services will schedule a downtime too.'
+ ),
+ 'value' => $this->hostDowntimeAllServices
+ ]
+ );
+ $decorator->decorate($this->getElement('all_services'));
+
+ $this->addElement(
+ 'select',
+ 'child_options',
+ array(
+ 'description' => t('Schedule child downtimes.'),
+ 'label' => t('Child Options'),
+ 'options' => [
+ 0 => t('Do nothing with child hosts'),
+ 1 => t('Schedule triggered downtime for all child hosts'),
+ 2 => t('Schedule non-triggered downtime for all child hosts')
+ ]
+ )
+ );
+ $decorator->decorate($this->getElement('child_options'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/downtime/schedule', $objects);
+
+ if ($granted->valid()) {
+ if (($childOptions = (int) $this->getValue('child_options'))) {
+ $command = new PropagateHostDowntimeCommand();
+ $command->setTriggered($childOptions === 1);
+ } else {
+ $command = new ScheduleHostDowntimeCommand();
+ }
+
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+ $command->setStart($this->getValue('start')->getTimestamp());
+ $command->setEnd($this->getValue('end')->getTimestamp());
+ $command->setForAllServices($this->getElement('all_services')->isChecked());
+
+ if ($this->getElement('flexible')->isChecked()) {
+ $command->setFixed(false);
+ $command->setDuration(
+ $this->getValue('hours') * 3600 + $this->getValue('minutes') * 60
+ );
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleServiceDowntimeForm.php b/application/forms/Command/Object/ScheduleServiceDowntimeForm.php
new file mode 100644
index 0000000..184a4e8
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleServiceDowntimeForm.php
@@ -0,0 +1,267 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\ScheduleServiceDowntimeCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class ScheduleServiceDowntimeForm extends CommandForm
+{
+ /** @var DateTime downtime start */
+ protected $start;
+
+ /** @var DateTime fixed downtime end */
+ protected $fixedEnd;
+
+ /**@var DateTime flexible downtime end */
+ protected $flexibleEnd;
+
+ /** @var DateInterval flexible downtime duration */
+ protected $flexibleDuration;
+
+ /** @var mixed Comment Text */
+ protected $commentText;
+
+ /**
+ * Initialize this form
+ */
+ public function __construct()
+ {
+ $this->start = new DateTime();
+
+ $config = Config::module('icingadb');
+
+ $this->commentText = $config->get('settings', 'hostdowntime_comment_text');
+ $fixedEnd = clone $this->start;
+ $fixed = $config->get('settings', 'servicedowntime_end_fixed', 'PT1H');
+ $this->fixedEnd = $fixedEnd->add(new DateInterval($fixed));
+
+ $flexibleEnd = clone $this->start;
+ $flexible = $config->get('settings', 'servicedowntime_end_flexible', 'PT2H');
+ $this->flexibleEnd = $flexibleEnd->add(new DateInterval($flexible));
+
+ $flexibleDuration = $config->get('settings', 'servicedowntime_flexible_duration', 'PT2H');
+ $this->flexibleDuration = new DateInterval($flexibleDuration);
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Scheduled downtime successfully', 'Scheduled downtime for %d services successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $isFlexible = $this->getPopulatedValue('flexible') === 'y';
+
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t(
+ 'This command is used to schedule host and service downtimes. During the downtime specified'
+ . ' by the start and end time, Icinga will not send notifications out about the hosts and'
+ . ' services. When the scheduled downtime expires, Icinga will send out notifications for'
+ . ' the hosts and services as it normally would.'
+ ))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'If you work with other administrators, you may find it useful to share information about'
+ . ' the host or service that is having problems. Make sure you enter a brief description of'
+ . ' what you are doing.'
+ ),
+ 'value' => $this->commentText
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $this->addElement(
+ 'localDateTime',
+ 'start',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'value' => $this->start,
+ 'label' => t('Start Time'),
+ 'description' => t('Set the start date and time for the downtime.')
+ ]
+ );
+ $decorator->decorate($this->getElement('start'));
+
+ $this->addElement(
+ 'localDateTime',
+ 'end',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'label' => t('End Time'),
+ 'description' => t('Set the end date and time for the downtime.'),
+ 'value' => $isFlexible ? $this->flexibleEnd : $this->fixedEnd,
+ 'validators' => [
+ 'DateTime' => ['break_chain_on_failure' => true],
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+
+ if ($value <= $this->getValue('start')) {
+ $validator->addMessage(t('The end time must be greater than the start time'));
+ return false;
+ }
+
+ if ($value <= (new DateTime())) {
+ $validator->addMessage(t('A downtime must not be in the past'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('end'));
+
+ $this->addElement(
+ 'checkbox',
+ 'flexible',
+ [
+ 'class' => 'autosubmit',
+ 'label' => t('Flexible'),
+ 'description' => t(
+ 'To make this a flexible downtime, check this option. A flexible downtime starts when the host'
+ . ' or service enters a problem state sometime between the start and end times you specified.'
+ . ' It then lasts as long as the duration time you enter.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('flexible'));
+
+ if ($isFlexible) {
+ $hoursInput = $this->createElement(
+ 'number',
+ 'hours',
+ [
+ 'required' => true,
+ 'label' => t('Duration'),
+ 'value' => $this->flexibleDuration->h,
+ 'min' => 0,
+ 'validators' => [
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+
+ if ($this->getValue('minutes') == 0 && $value == 0) {
+ $validator->addMessage(t('The duration must not be zero'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $this->registerElement($hoursInput);
+ $decorator->decorate($hoursInput);
+
+ $minutesInput = $this->createElement(
+ 'number',
+ 'minutes',
+ [
+ 'required' => true,
+ 'value' => $this->flexibleDuration->m,
+ 'min' => 0
+ ]
+ );
+ $this->registerElement($minutesInput);
+ $minutesInput->addWrapper(
+ new HtmlElement('label', null, new HtmlElement('span', null, Text::create(t('Minutes'))))
+ );
+
+ $hoursInput->getWrapper()->on(
+ IcingaFormDecorator::ON_ASSEMBLED,
+ function ($hoursInputWrapper) use ($minutesInput, $hoursInput) {
+ $hoursInputWrapper
+ ->insertAfter($minutesInput, $hoursInput)
+ ->getAttributes()->add('class', 'downtime-duration');
+ }
+ );
+
+ $hoursInput->prependWrapper(
+ new HtmlElement('label', null, new HtmlElement('span', null, Text::create(t('Hours'))))
+ );
+
+ $this->add($hoursInput);
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Schedule downtime', 'Schedule downtimes', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/downtime/schedule', $objects);
+
+ if ($granted->valid()) {
+ $command = new ScheduleServiceDowntimeCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+ $command->setStart($this->getValue('start')->getTimestamp());
+ $command->setEnd($this->getValue('end')->getTimestamp());
+
+ if ($this->getElement('flexible')->isChecked()) {
+ $command->setFixed(false);
+ $command->setDuration(
+ $this->getValue('hours') * 3600 + $this->getValue('minutes') * 60
+ );
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/SendCustomNotificationForm.php b/application/forms/Command/Object/SendCustomNotificationForm.php
new file mode 100644
index 0000000..dfb1e96
--- /dev/null
+++ b/application/forms/Command/Object/SendCustomNotificationForm.php
@@ -0,0 +1,125 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\SendCustomNotificationCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class SendCustomNotificationForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Sent custom notification successfully',
+ 'Sent custom notification for %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Sent custom notification successfully',
+ 'Sent custom notification for %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t('This command is used to send custom notifications about hosts or services.'))
+ )
+ )
+ ));
+
+ $config = Config::module('icingadb');
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'Enter a brief description on why you\'re sending this notification. It will be sent with it.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $this->addElement(
+ 'checkbox',
+ 'forced',
+ [
+ 'label' => t('Forced'),
+ 'value' => (bool) $config->get('settings', 'custom_notification_forced', false),
+ 'description' => t(
+ 'If you check this option, the notification is sent regardless'
+ . ' of downtimes or whether notifications are enabled or not.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('forced'));
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Send custom notification', 'Send custom notifications', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/send-custom-notification', $objects);
+
+ if ($granted->valid()) {
+ $command = new SendCustomNotificationCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setForced($this->getElement('forced')->isChecked());
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ToggleObjectFeaturesForm.php b/application/forms/Command/Object/ToggleObjectFeaturesForm.php
new file mode 100644
index 0000000..50767da
--- /dev/null
+++ b/application/forms/Command/Object/ToggleObjectFeaturesForm.php
@@ -0,0 +1,186 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Html\FormElement\CheckboxElement;
+use ipl\Orm\Model;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use Traversable;
+
+class ToggleObjectFeaturesForm extends CommandForm
+{
+ const LEAVE_UNCHANGED = 'noop';
+
+ protected $features;
+
+ protected $featureStatus;
+
+ /**
+ * ToggleFeature(s) being used to submit this form
+ *
+ * @var ToggleObjectFeatureCommand[]
+ */
+ protected $submittedFeatures = [];
+
+ public function __construct($featureStatus)
+ {
+ $this->featureStatus = $featureStatus;
+ $this->features = [
+ ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS => [
+ 'label' => t('Active Checks'),
+ 'permission' => 'icingadb/command/feature/object/active-checks'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS => [
+ 'label' => t('Passive Checks'),
+ 'permission' => 'icingadb/command/feature/object/passive-checks'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS => [
+ 'label' => t('Notifications'),
+ 'permission' => 'icingadb/command/feature/object/notifications'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER => [
+ 'label' => t('Event Handler'),
+ 'permission' => 'icingadb/command/feature/object/event-handler'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION => [
+ 'label' => t('Flap Detection'),
+ 'permission' => 'icingadb/command/feature/object/flap-detection'
+ ]
+ ];
+
+ $this->getAttributes()->add('class', 'object-features');
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ foreach ($this->submittedFeatures as $feature) {
+ $enabled = $feature->getEnabled();
+ switch ($feature->getFeature()) {
+ case ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled active checks successfully');
+ } else {
+ $message = t('Disabled active checks successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled passive checks successfully');
+ } else {
+ $message = t('Disabled passive checks successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER:
+ if ($enabled) {
+ $message = t('Enabled event handler successfully');
+ } else {
+ $message = t('Disabled event handler checks successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION:
+ if ($enabled) {
+ $message = t('Enabled flap detection successfully');
+ } else {
+ $message = t('Disabled flap detection successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS:
+ if ($enabled) {
+ $message = t('Enabled notifications successfully');
+ } else {
+ $message = t('Disabled notifications successfully');
+ }
+
+ break;
+ default:
+ $message = t('Invalid feature option');
+ break;
+ }
+
+ Notification::success($message);
+ }
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $decorator = new IcingaFormDecorator();
+ foreach ($this->features as $feature => $spec) {
+ $options = [
+ 'class' => 'autosubmit',
+ 'disabled' => $this->featureStatus instanceof Model
+ ? ! $this->isGrantedOn($spec['permission'], $this->featureStatus)
+ : false,
+ 'label' => $spec['label']
+ ];
+ if ($this->featureStatus[$feature] === 2) {
+ $this->addElement(
+ 'select',
+ $feature,
+ $options + [
+ 'description' => t('Multiple Values'),
+ 'options' => [
+ self::LEAVE_UNCHANGED => t('Leave Unchanged'),
+ t('Disable All'),
+ t('Enable All')
+ ],
+ 'value' => self::LEAVE_UNCHANGED
+ ]
+ );
+ $decorator->decorate($this->getElement($feature));
+
+ $this->getElement($feature)
+ ->getWrapper()
+ ->getAttributes()
+ ->add('class', 'indeterminate');
+ } else {
+ $options['value'] = (bool) $this->featureStatus[$feature];
+ $this->addElement('checkbox', $feature, $options);
+ $decorator->decorate($this->getElement($feature));
+ }
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ foreach ($this->features as $feature => $spec) {
+ if ($this->getElement($feature) instanceof CheckboxElement) {
+ $state = $this->getElement($feature)->isChecked();
+ } else {
+ $state = $this->getElement($feature)->getValue();
+ }
+
+ if ($state === self::LEAVE_UNCHANGED || (int) $state === (int) $this->featureStatus[$feature]) {
+ continue;
+ }
+
+ $granted = $this->filterGrantedOn($spec['permission'], $objects);
+
+ if ($granted->valid()) {
+ $command = new ToggleObjectFeatureCommand();
+ $command->setObjects($granted);
+ $command->setFeature($feature);
+ $command->setEnabled((int) $state);
+
+ $this->submittedFeatures[] = $command;
+
+ yield $command;
+ }
+ }
+ }
+}
diff --git a/application/forms/DatabaseConfigForm.php b/application/forms/DatabaseConfigForm.php
new file mode 100644
index 0000000..7a6c1bd
--- /dev/null
+++ b/application/forms/DatabaseConfigForm.php
@@ -0,0 +1,33 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Icinga\Data\ResourceFactory;
+use Icinga\Forms\ConfigForm;
+
+class DatabaseConfigForm extends ConfigForm
+{
+ public function init()
+ {
+ $this->setSubmitLabel(t('Save Changes'));
+ }
+
+ public function createElements(array $formData)
+ {
+ $dbResources = ResourceFactory::getResourceConfigs('db')->keys();
+
+ $this->addElement('select', 'icingadb_resource', [
+ 'description' => t('Database resource'),
+ 'label' => t('Database'),
+ 'multiOptions' => array_merge(
+ ['' => sprintf(' - %s - ', t('Please choose'))],
+ array_combine($dbResources, $dbResources)
+ ),
+ 'disable' => [''],
+ 'required' => true,
+ 'value' => ''
+ ]);
+ }
+}
diff --git a/application/forms/Navigation/ActionForm.php b/application/forms/Navigation/ActionForm.php
new file mode 100644
index 0000000..08cba3f
--- /dev/null
+++ b/application/forms/Navigation/ActionForm.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Navigation;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Forms\Navigation\NavigationItemForm;
+use Icinga\Module\Icingadb\Common\Auth;
+
+class ActionForm extends NavigationItemForm
+{
+ use Auth;
+
+ /**
+ * The name of the restriction to which the filter should be applied
+ *
+ * @var string
+ */
+ protected $restriction;
+
+ public function createElements(array $formData)
+ {
+ parent::createElements($formData);
+
+ $this->addElement(
+ 'text',
+ 'filter',
+ array(
+ 'allowEmpty' => true,
+ 'label' => $this->translate('Filter'),
+ 'description' => $this->translate(
+ 'Display this action only for objects matching this filter. Leave it blank'
+ . ' if you want this action being displayed regardless of the object'
+ )
+ )
+ );
+ }
+
+ public function isValid($formData): bool
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (($filterString = $this->getValue('filter')) !== null) {
+ try {
+ $this->parseRestriction($filterString, $this->restriction);
+ } catch (ConfigurationError $err) {
+ $this->getElement('filter')->addError($err->getMessage());
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/application/forms/Navigation/IcingadbHostActionForm.php b/application/forms/Navigation/IcingadbHostActionForm.php
new file mode 100644
index 0000000..adee11d
--- /dev/null
+++ b/application/forms/Navigation/IcingadbHostActionForm.php
@@ -0,0 +1,10 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Navigation;
+
+class IcingadbHostActionForm extends ActionForm
+{
+ protected $restriction = 'icingadb/filter/hosts';
+}
diff --git a/application/forms/Navigation/IcingadbServiceActionForm.php b/application/forms/Navigation/IcingadbServiceActionForm.php
new file mode 100644
index 0000000..29d33c8
--- /dev/null
+++ b/application/forms/Navigation/IcingadbServiceActionForm.php
@@ -0,0 +1,10 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Navigation;
+
+class IcingadbServiceActionForm extends ActionForm
+{
+ protected $restriction = 'icingadb/filter/services';
+}
diff --git a/application/forms/RedisConfigForm.php b/application/forms/RedisConfigForm.php
new file mode 100644
index 0000000..bd3db9c
--- /dev/null
+++ b/application/forms/RedisConfigForm.php
@@ -0,0 +1,606 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Closure;
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Exception\NotWritableError;
+use Icinga\File\Storage\LocalFileStorage;
+use Icinga\File\Storage\TemporaryLocalFileStorage;
+use Icinga\Forms\ConfigForm;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Web\Form;
+use ipl\Validator\PrivateKeyValidator;
+use ipl\Validator\X509CertValidator;
+use Zend_Validate_Callback;
+
+class RedisConfigForm extends ConfigForm
+{
+ public function init()
+ {
+ $this->setSubmitLabel(t('Save Changes'));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ $this->addElement('checkbox', 'redis_tls', [
+ 'label' => t('Use TLS'),
+ 'description' => t('Encrypt connections to Redis via TLS'),
+ 'autosubmit' => true
+ ]);
+
+ $this->addElement('hidden', 'redis_ca');
+ $this->addElement('hidden', 'redis_cert');
+ $this->addElement('hidden', 'redis_key');
+ $this->addElement('hidden', 'clear_redis_ca', ['ignore' => true]);
+ $this->addElement('hidden', 'clear_redis_cert', ['ignore' => true]);
+ $this->addElement('hidden', 'clear_redis_key', ['ignore' => true]);
+
+ $useTls = isset($formData['redis_tls']) && $formData['redis_tls'];
+ if ($useTls) {
+ $this->addElement('textarea', 'redis_ca_pem', [
+ 'label' => t('Redis CA Certificate'),
+ 'description' => sprintf(
+ t('Verify the peer using this PEM-encoded CA certificate ("%s...")'),
+ '-----BEGIN CERTIFICATE-----'
+ ),
+ 'required' => true,
+ 'ignore' => true,
+ 'validators' => [$this->wrapIplValidator(X509CertValidator::class, 'redis_ca_pem')]
+ ]);
+
+ $this->addElement('textarea', 'redis_cert_pem', [
+ 'label' => t('Client Certificate'),
+ 'description' => sprintf(
+ t('Authenticate using this PEM-encoded client certificate ("%s...")'),
+ '-----BEGIN CERTIFICATE-----'
+ ),
+ 'ignore' => true,
+ 'allowEmpty' => false,
+ 'validators' => [
+ $this->wrapIplValidator(X509CertValidator::class, 'redis_cert_pem', function ($value) {
+ if (! $value && $this->getElement('redis_key_pem')->getValue()) {
+ $this->getElement('redis_cert_pem')->addError(t(
+ 'Either both a client certificate and its private key or none of them must be specified'
+ ));
+ }
+
+ return true;
+ })
+ ]
+ ]);
+
+ $this->addElement('textarea', 'redis_key_pem', [
+ 'label' => t('Client Key'),
+ 'description' => sprintf(
+ t('Authenticate using this PEM-encoded private key ("%s...")'),
+ '-----BEGIN PRIVATE KEY-----'
+ ),
+ 'ignore' => true,
+ 'allowEmpty' => false,
+ 'validators' => [
+ $this->wrapIplValidator(PrivateKeyValidator::class, 'redis_key_pem', function ($value) {
+ if (! $value && $this->getElement('redis_cert_pem')->getValue()) {
+ $this->getElement('redis_key_pem')->addError(t(
+ 'Either both a client certificate and its private key or none of them must be specified'
+ ));
+ }
+
+ return true;
+ })
+ ]
+ ]);
+ }
+
+ $this->addDisplayGroup(
+ ['redis_tls', 'redis_insecure', 'redis_ca_pem', 'redis_cert_pem', 'redis_key_pem'],
+ 'redis',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'Secure connections. If you are running a high availability zone'
+ . ' with two masters, the following applies to both of them.'
+ ),
+ 'legend' => t('General')
+ ]
+ );
+
+ if (isset($formData['skip_validation']) && $formData['skip_validation']) {
+ // In case another error occured and the checkbox was displayed before
+ static::addSkipValidationCheckbox($this);
+ }
+
+ if ($useTls && isset($formData['redis_insecure']) && $formData['redis_insecure']) {
+ // In case another error occured and the checkbox was displayed before
+ static::addInsecureCheckboxIfTls($this);
+ }
+
+ $this->addElement('text', 'redis1_host', [
+ 'description' => t('Redis Host'),
+ 'label' => t('Redis Host'),
+ 'required' => true
+ ]);
+
+ $this->addElement('number', 'redis1_port', [
+ 'description' => t('Redis Port'),
+ 'label' => t('Redis Port'),
+ 'placeholder' => 6380
+ ]);
+
+ $this->addElement('password', 'redis1_password', [
+ 'description' => t('Redis Password'),
+ 'label' => t('Redis Password'),
+ 'renderPassword' => true,
+ 'autocomplete' => 'new-password'
+ ]);
+
+ $this->addDisplayGroup(
+ ['redis1_host', 'redis1_port', 'redis1_password'],
+ 'redis1',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'Redis connection details of your Icinga host. If you are running a high'
+ . ' availability zone with two masters, this is your configuration master.'
+ ),
+ 'legend' => t('Primary Icinga Master')
+ ]
+ );
+
+ $this->addElement('text', 'redis2_host', [
+ 'description' => t('Redis Host'),
+ 'label' => t('Redis Host'),
+ ]);
+
+ $this->addElement('number', 'redis2_port', [
+ 'description' => t('Redis Port'),
+ 'label' => t('Redis Port'),
+ 'placeholder' => 6380
+ ]);
+
+ $this->addElement('password', 'redis2_password', [
+ 'description' => t('Redis Password'),
+ 'label' => t('Redis Password'),
+ 'renderPassword' => true,
+ 'autocomplete' => 'new-password'
+ ]);
+
+ $this->addDisplayGroup(
+ ['redis2_host', 'redis2_port', 'redis2_password'],
+ 'redis2',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'If you are running a high availability zone with two masters,'
+ . ' please provide the Redis connection details of the secondary master.'
+ ),
+ 'legend' => t('Secondary Icinga Master')
+ ]
+ );
+ }
+
+ public static function addSkipValidationCheckbox(Form $form)
+ {
+ $form->addElement(
+ 'checkbox',
+ 'skip_validation',
+ [
+ 'order' => 0,
+ 'ignore' => true,
+ 'label' => t('Skip Validation'),
+ 'description' => t(
+ 'Check this box to enforce changes without validating that Redis is available.'
+ )
+ ]
+ );
+ }
+
+ public static function addInsecureCheckboxIfTls(Form $form)
+ {
+ if ($form->getElement('redis_insecure') !== null) {
+ return;
+ }
+
+ $form->addElement(
+ 'checkbox',
+ 'redis_insecure',
+ [
+ 'order' => 1,
+ 'label' => t('Insecure'),
+ 'description' => t('Don\'t verify the peer')
+ ]
+ );
+
+ $displayGroup = $form->getDisplayGroup('redis');
+ $elements = $displayGroup->getElements();
+ $elements['redis_insecure'] = $form->getElement('redis_insecure');
+ $displayGroup->setElements($elements);
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (($el = $this->getElement('skip_validation')) === null || ! $el->isChecked()) {
+ if (! static::checkRedis($this)) {
+ if ($el === null) {
+ static::addSkipValidationCheckbox($this);
+
+ if ($this->getElement('redis_tls')->isChecked()) {
+ static::addInsecureCheckboxIfTls($this);
+ }
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (! parent::isValidPartial($formData)) {
+ return false;
+ }
+
+ $useTls = $this->getElement('redis_tls')->isChecked();
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textareaName = 'redis_' . $name . '_pem';
+ $clearName = 'clear_redis_' . $name;
+
+ if ($useTls) {
+ $this->getElement($clearName)->setValue(null);
+
+ $pemPath = $this->getValue('redis_' . $name);
+ if ($pemPath && ! isset($formData[$textareaName]) && ! $formData[$clearName]) {
+ $this->getElement($textareaName)->setValue(@file_get_contents($pemPath));
+ }
+ }
+
+ if (isset($formData[$textareaName]) && ! $formData[$textareaName]) {
+ $this->getElement($clearName)->setValue(true);
+ }
+ }
+
+ if ($this->getElement('backend_validation')->isChecked()) {
+ if (! static::checkRedis($this)) {
+ if ($this->getElement('redis_tls')->isChecked()) {
+ static::addInsecureCheckboxIfTls($this);
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function onRequest()
+ {
+ $errors = [];
+
+ $redisConfig = $this->config->getSection('redis');
+ if ($redisConfig->get('tls', false)) {
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $path = $redisConfig->get($name);
+ if (file_exists($path)) {
+ try {
+ $redisConfig[$name . '_pem'] = file_get_contents($path);
+ } catch (Exception $e) {
+ $errors['redis_' . $name . '_pem'] = sprintf(
+ t('Failed to read file "%s": %s'),
+ $path,
+ $e->getMessage()
+ );
+ }
+ }
+ }
+ }
+
+ $connectionConfig = Config::fromIni(
+ join(DIRECTORY_SEPARATOR, [dirname($this->config->getConfigFile()), 'redis.ini'])
+ );
+ $this->config->setSection('redis1', [
+ 'host' => $connectionConfig->get('redis1', 'host'),
+ 'port' => $connectionConfig->get('redis1', 'port'),
+ 'password' => $connectionConfig->get('redis1', 'password')
+ ]);
+ $this->config->setSection('redis2', [
+ 'host' => $connectionConfig->get('redis2', 'host'),
+ 'port' => $connectionConfig->get('redis2', 'port'),
+ 'password' => $connectionConfig->get('redis2', 'password')
+ ]);
+
+ parent::onRequest();
+
+ foreach ($errors as $elementName => $message) {
+ $this->getElement($elementName)->addError($message);
+ }
+ }
+
+ public function onSuccess()
+ {
+ $storage = new LocalFileStorage(Icinga::app()->getStorageDir(
+ join(DIRECTORY_SEPARATOR, ['modules', 'icingadb', 'redis'])
+ ));
+
+ $useTls = $this->getElement('redis_tls')->isChecked();
+ $pem = null;
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textarea = $this->getElement('redis_' . $name . '_pem');
+ if ($useTls && $textarea !== null && ($pem = $textarea->getValue())) {
+ $pemFile = md5($pem) . '-' . $name . '.pem';
+ if (! $storage->has($pemFile)) {
+ try {
+ $storage->create($pemFile, $pem);
+ } catch (NotWritableError $e) {
+ $textarea->addError($e->getMessage());
+ return false;
+ }
+ }
+
+ $this->getElement('redis_' . $name)->setValue($storage->resolvePath($pemFile));
+ }
+
+ if ((! $useTls && $this->getElement('clear_redis_' . $name)->getValue()) || ($useTls && ! $pem)) {
+ $pemPath = $this->getValue('redis_' . $name);
+ if ($pemPath && $storage->has(($pemFile = basename($pemPath)))) {
+ try {
+ $storage->delete($pemFile);
+ $this->getElement('redis_' . $name)->setValue(null);
+ } catch (NotWritableError $e) {
+ $this->addError($e->getMessage());
+ return false;
+ }
+ }
+ }
+ }
+
+ $connectionConfig = Config::fromIni(
+ join(DIRECTORY_SEPARATOR, [dirname($this->config->getConfigFile()), 'redis.ini'])
+ );
+
+ $redis1Host = $this->getValue('redis1_host');
+ $redis1Port = $this->getValue('redis1_port');
+ $redis1Password = $this->getValue('redis1_password');
+ $redis1Section = $connectionConfig->getSection('redis1');
+ $redis1Section['host'] = $redis1Host;
+ $this->getElement('redis1_host')->setValue(null);
+ $connectionConfig->setSection('redis1', $redis1Section);
+ if (! empty($redis1Port)) {
+ $redis1Section['port'] = $redis1Port;
+ $this->getElement('redis1_port')->setValue(null);
+ } else {
+ $redis1Section['port'] = null;
+ }
+
+ if (! empty($redis1Password)) {
+ $redis1Section['password'] = $redis1Password;
+ $this->getElement('redis1_password')->setValue(null);
+ } else {
+ $redis1Section['password'] = null;
+ }
+
+ if (! array_filter($redis1Section->toArray())) {
+ $connectionConfig->removeSection('redis1');
+ }
+
+ $redis2Host = $this->getValue('redis2_host');
+ $redis2Port = $this->getValue('redis2_port');
+ $redis2Password = $this->getValue('redis2_password');
+ $redis2Section = $connectionConfig->getSection('redis2');
+ if (! empty($redis2Host)) {
+ $redis2Section['host'] = $redis2Host;
+ $this->getElement('redis2_host')->setValue(null);
+ $connectionConfig->setSection('redis2', $redis2Section);
+ } else {
+ $redis2Section['host'] = null;
+ }
+
+ if (! empty($redis2Port)) {
+ $redis2Section['port'] = $redis2Port;
+ $this->getElement('redis2_port')->setValue(null);
+ $connectionConfig->setSection('redis2', $redis2Section);
+ } else {
+ $redis2Section['port'] = null;
+ }
+
+ if (! empty($redis2Password)) {
+ $redis2Section['password'] = $redis2Password;
+ $this->getElement('redis2_password')->setValue(null);
+ } else {
+ $redis2Section['password'] = null;
+ }
+
+ if (! array_filter($redis2Section->toArray())) {
+ $connectionConfig->removeSection('redis2');
+ }
+
+ $connectionConfig->saveIni();
+
+ return parent::onSuccess();
+ }
+
+ public function addSubmitButton()
+ {
+ parent::addSubmitButton()
+ ->getElement('btn_submit')
+ ->setDecorators(['ViewHelper']);
+
+ $this->addElement(
+ 'submit',
+ 'backend_validation',
+ [
+ 'ignore' => true,
+ 'label' => t('Validate Configuration'),
+ 'data-progress-label' => t('Validation In Progress'),
+ 'decorators' => ['ViewHelper']
+ ]
+ );
+ $this->addDisplayGroup(
+ ['btn_submit', 'backend_validation'],
+ 'submit_validation',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div', 'class' => 'control-group form-controls']]
+ ]
+ ]
+ );
+
+ return $this;
+ }
+
+ public static function checkRedis(Form $form): bool
+ {
+ $sections = [];
+
+ $storage = new TemporaryLocalFileStorage();
+ foreach (ConfigForm::transformEmptyValuesToNull($form->getValues()) as $sectionAndPropertyName => $value) {
+ if ($value !== null) {
+ list($section, $property) = explode('_', $sectionAndPropertyName, 2);
+ if (in_array($property, ['ca', 'cert', 'key'])) {
+ $storage->create("$property.pem", $value);
+ $value = $storage->resolvePath("$property.pem");
+ }
+
+ $sections[$section][$property] = $value;
+ }
+ }
+
+ $ignoredTextAreas = [
+ 'ca' => 'redis_ca_pem',
+ 'cert' => 'redis_cert_pem',
+ 'key' => 'redis_key_pem'
+ ];
+ foreach ($ignoredTextAreas as $name => $textareaName) {
+ if (($textarea = $form->getElement($textareaName)) !== null) {
+ if (($pem = $textarea->getValue())) {
+ if ($storage->has("$name.pem")) {
+ $storage->update("$name.pem", $pem);
+ } else {
+ $storage->create("$name.pem", $pem);
+ $sections['redis'][$name] = $storage->resolvePath("$name.pem");
+ }
+ } elseif ($storage->has("$name.pem")) {
+ $storage->delete("$name.pem");
+ unset($sections['redis'][$name]);
+ }
+ }
+ }
+
+ $moduleConfig = new Config();
+ $moduleConfig->setSection('redis', $sections['redis']);
+ $redisConfig = new Config();
+ $redisConfig->setSection('redis1', $sections['redis1'] ?? []);
+ $redisConfig->setSection('redis2', $sections['redis2'] ?? []);
+
+ try {
+ $redis1 = IcingaRedis::getPrimaryRedis($moduleConfig, $redisConfig);
+ } catch (Exception $e) {
+ $form->warning(sprintf(
+ t('Failed to connect to primary Redis: %s'),
+ $e->getMessage()
+ ));
+ return false;
+ }
+
+ if (IcingaRedis::getLastIcingaHeartbeat($redis1) === null) {
+ $form->warning(t('Primary connection established but failed to verify Icinga is connected as well.'));
+ return false;
+ }
+
+ try {
+ $redis2 = IcingaRedis::getSecondaryRedis($moduleConfig, $redisConfig);
+ } catch (Exception $e) {
+ $form->warning(sprintf(t('Failed to connect to secondary Redis: %s'), $e->getMessage()));
+ return false;
+ }
+
+ if ($redis2 !== null && IcingaRedis::getLastIcingaHeartbeat($redis2) === null) {
+ $form->warning(t('Secondary connection established but failed to verify Icinga is connected as well.'));
+ return false;
+ }
+
+ $form->info(t('The configuration has been successfully validated.'));
+ return true;
+ }
+
+ /**
+ * Wraps the given IPL validator class into a callback validator
+ * for usage as the only validator of the element given by name.
+ *
+ * @param string $cls IPL validator class FQN
+ * @param string $element Form element name
+ * @param Closure $additionalValidator
+ *
+ * @return array Callback validator
+ */
+ private function wrapIplValidator(string $cls, string $element, Closure $additionalValidator = null): array
+ {
+ return [
+ 'Callback',
+ false,
+ [
+ 'callback' => function ($v) use ($cls, $element, $additionalValidator) {
+ if ($additionalValidator !== null) {
+ if (! $additionalValidator($v)) {
+ return false;
+ }
+ }
+
+ if (! $v) {
+ return true;
+ }
+
+ $validator = new $cls();
+ $valid = $validator->isValid($v);
+
+ if (! $valid) {
+ /** @var Zend_Validate_Callback $callbackValidator */
+ $callbackValidator = $this->getElement($element)->getValidator('Callback');
+
+ $callbackValidator->setMessage(
+ $validator->getMessages()[0],
+ Zend_Validate_Callback::INVALID_VALUE
+ );
+ }
+
+ return $valid;
+ }
+ ]
+ ];
+ }
+}
diff --git a/application/forms/SetAsBackendForm.php b/application/forms/SetAsBackendForm.php
new file mode 100644
index 0000000..2840633
--- /dev/null
+++ b/application/forms/SetAsBackendForm.php
@@ -0,0 +1,34 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Icinga\Module\Icingadb\Hook\IcingadbSupportHook;
+use Icinga\Web\Session;
+use ipl\Web\Compat\CompatForm;
+
+class SetAsBackendForm extends CompatForm
+{
+ protected $defaultAttributes = [
+ 'id' => 'setAsBackendForm',
+ 'class' => 'icinga-controls'
+ ];
+
+ protected function assemble()
+ {
+ $this->addElement('checkbox', 'backend', [
+ 'class' => 'autosubmit',
+ 'label' => t('Use Icinga DB As Backend'),
+ 'value' => IcingadbSupportHook::isIcingaDbSetAsPreferredBackend()
+ ]);
+ }
+
+ public function onSuccess()
+ {
+ Session::getSession()->getNamespace('icingadb')->set(
+ IcingadbSupportHook::PREFERENCE_NAME,
+ $this->getElement('backend')->isChecked()
+ );
+ }
+}
diff --git a/application/views/scripts/joystickPagination-icingadb.phtml b/application/views/scripts/joystickPagination-icingadb.phtml
new file mode 100644
index 0000000..122d72a
--- /dev/null
+++ b/application/views/scripts/joystickPagination-icingadb.phtml
@@ -0,0 +1,162 @@
+<?php
+
+use Icinga\Web\Url;
+
+$showText = $this->translate('%s: Show %s %u to %u out of %u', 'pagination.joystick');
+$flipUrl = clone $baseUrl;
+if ($flipUrl->getParam('flipped')) {
+ $flipUrl->remove('flipped');
+} else {
+ $flipUrl->setParam('flipped');
+}
+if ($flipUrl->hasParam('page')) {
+ $flipUrl->setParam('page', implode(',', array_reverse(explode(',', $flipUrl->getParam('page')))));
+}
+if ($flipUrl->hasParam('limit')) {
+ $flipUrl->setParam('limit', implode(',', array_reverse(explode(',', $flipUrl->getParam('limit')))));
+}
+
+$yAxisTotalItem = $yAxisPaginator->count();
+$yAxisItemCountPerPage = $yAxisPaginator->getLimit() ?? $yAxisTotalItem;
+$totalYAxisPages = ceil($yAxisTotalItem / $yAxisItemCountPerPage);
+$currentYAxisPage = round($yAxisPaginator->getOffset() / $yAxisItemCountPerPage) + 1;
+$prevYAxisPage = $currentYAxisPage > 1 ? $currentYAxisPage - 1 : null;
+$nextYAxisPage = $currentYAxisPage < $totalYAxisPages ? $currentYAxisPage + 1 : null;
+
+$xAxisTotalItem = $xAxisPaginator->count();
+$xAxisItemCountPerPage = $xAxisPaginator->getLimit() ?? $xAxisTotalItem;
+$totalXAxisPages = ceil($xAxisTotalItem / $xAxisItemCountPerPage);
+$currentXAxisPage = round($xAxisPaginator->getOffset() / $xAxisItemCountPerPage) + 1;
+$prevXAxisPage = $currentXAxisPage > 1 ? $currentXAxisPage - 1 : null;
+$nextXAxisPage = $currentXAxisPage < $totalXAxisPages ? $currentXAxisPage + 1 : null;
+?>
+
+<table class="joystick-pagination">
+ <tbody>
+ <tr>
+ <td>&nbsp;</td>
+ <td>
+ <?php if ($prevYAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $currentXAxisPage . ',' . $prevYAxisPage
+ ),
+ array(
+ 'icon' => 'up-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('Y-Axis', 'pagination.joystick'),
+ $this->translate('hosts', 'pagination.joystick'),
+ ($prevYAxisPage - 1) * $xAxisItemCountPerPage + 1,
+ $prevYAxisPage * $xAxisItemCountPerPage,
+ $yAxisTotalItem
+ )
+ )
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('up-open'); ?>
+ <?php endif ?>
+ </td>
+ <td>&nbsp;</td>
+ </tr>
+ <tr>
+ <td>
+ <?php if ($prevXAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $prevXAxisPage . ',' . $currentYAxisPage
+ ),
+ array(
+ 'icon' => 'left-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('X-Axis', 'pagination.joystick'),
+ $this->translate('services', 'pagination.joystick'),
+ ($prevXAxisPage - 1) * $xAxisItemCountPerPage + 1,
+ $prevXAxisPage * $xAxisItemCountPerPage,
+ $xAxisTotalItem
+ )
+ )
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('left-open'); ?>
+ <?php endif ?>
+ </td>
+ <?php if ($this->flippable): ?>
+ <td><?= $this->qlink(
+ '',
+ $flipUrl,
+ null,
+ array(
+ 'icon' => 'arrows-cw',
+ 'data-base-target' => '_self',
+ 'title' => $this->translate('Flip grid axes')
+ )
+ ) ?></td>
+ <?php else: ?>
+ <td>&nbsp;</td>
+ <?php endif ?>
+ <td>
+ <?php if ($nextXAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $nextXAxisPage . ',' . $currentYAxisPage
+ ),
+ array(
+ 'icon' => 'right-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('X-Axis', 'pagination.joystick'),
+ $this->translate('services', 'pagination.joystick'),
+ $currentXAxisPage * $xAxisItemCountPerPage + 1,
+ $nextXAxisPage === $totalXAxisPages ? $xAxisItemCountPerPage : $nextXAxisPage * $xAxisItemCountPerPage,
+ $xAxisTotalItem
+ )
+ ),
+ false
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('right-open'); ?>
+ <?php endif ?>
+ </td>
+ </tr>
+ <tr>
+ <td>&nbsp;</td>
+ <td>
+ <?php if ($nextYAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $currentXAxisPage . ',' . $nextYAxisPage
+ ),
+ array(
+ 'icon' => 'down-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('Y-Axis', 'pagination.joystick'),
+ $this->translate('hosts', 'pagination.joystick'),
+ $currentYAxisPage * $yAxisItemCountPerPage + 1,
+ $nextYAxisPage === $totalYAxisPages ? $yAxisItemCountPerPage : $nextYAxisPage * $yAxisItemCountPerPage,
+ $yAxisTotalItem
+ )
+ )
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('down-open'); ?>
+ <?php endif ?>
+ </td>
+ <td>&nbsp;</td>
+ </tr>
+ </tbody>
+</table>
diff --git a/application/views/scripts/services/grid-flipped.phtml b/application/views/scripts/services/grid-flipped.phtml
new file mode 100644
index 0000000..0642eb2
--- /dev/null
+++ b/application/views/scripts/services/grid-flipped.phtml
@@ -0,0 +1,149 @@
+<?php
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Web\Url;
+
+if (! $this->compact): ?>
+ <?= $this->controls ?>
+<?php endif ?>
+<div class="content" data-base-target="_next" id="<?= $this->protectId('content') ?>">
+ <?php if (empty($pivotData)): ?>
+ <div class="item-list">
+ <div class="empty-state-bar"><?= $this->translate('No services found matching the filter.') ?></div>
+ </div>
+</div>
+<?php return; endif;
+$serviceFilter = Filter::matchAny();
+foreach ($pivotData as $serviceDescription => $_) {
+ $serviceFilter->orFilter(Filter::where('service.name', $serviceDescription));
+}
+?>
+<table class="service-grid-table">
+ <thead>
+ <tr>
+ <th><?= $this->partial(
+ 'joystickPagination-icingadb.phtml',
+ 'default',
+ array(
+ 'flippable' => true,
+ 'baseUrl' => $baseUrl,
+ 'xAxisPaginator' => $horizontalPaginator,
+ 'yAxisPaginator' => $verticalPaginator
+ )
+ ) ?></th>
+ <?php foreach ($pivotHeader['cols'] as $hostName => $hostAlias): ?>
+ <th class="rotate-45"><div><span><?= $this->qlink(
+ $this->ellipsis($hostAlias, 24),
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($serviceFilter, Filter::where('host.name', $hostName))
+ ),
+ null,
+ array('title' => sprintf($this->translate('List all reported services on host %s'), $hostAlias)),
+ false
+ ) ?></span></div></th>
+ <?php endforeach ?>
+ </tr>
+ </thead>
+ <tbody>
+
+ <?php $i = 0 ?>
+ <?php foreach ($pivotHeader['rows'] as $serviceDescription => $serviceDisplayName): ?>
+ <tr>
+ <th><?php
+ $hostFilter = Filter::matchAny();
+ foreach ($pivotData[$serviceDescription] as $hostName => $_) {
+ $hostFilter->orFilter(Filter::where('host.name', $hostName));
+ }
+ echo $this->qlink(
+ $serviceDisplayName,
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($hostFilter, Filter::where('service.name', $serviceDescription))
+ ),
+ null,
+ array('title' => sprintf(
+ $this->translate('List all services with the name "%s" on all reported hosts'),
+ $serviceDisplayName
+ ))
+ );
+ ?></th>
+ <?php foreach (array_keys($pivotHeader['cols']) as $hostName): ?>
+ <td><?php
+ $service = $pivotData[$serviceDescription][$hostName];
+ if ($service === null): ?>
+ <span aria-hidden="true">&middot;</span>
+ <?php continue; endif ?>
+ <?php $ariaDescribedById = $this->protectId($service->host_name . '_' . $service->name . '_desc') ?>
+ <span class="sr-only" id="<?= $ariaDescribedById ?>">
+ <?= $this->escape($service->state->output) ?>
+ </span>
+ <?= $this->qlink(
+ '',
+ 'icingadb/services',
+ array(
+ 'host.name' => $hostName,
+ 'name' => $serviceDescription
+ ),
+ array(
+ 'aria-describedby' => $ariaDescribedById,
+ 'aria-label' => sprintf(
+ $this->translate('Show detailed information for service %s on host %s'),
+ $service->display_name,
+ $service->host_display_name
+ ),
+ 'class' => 'service-grid-link state-' . $service->state->getStateText() . ($service->state->is_handled ? ' handled' : ''),
+ 'title' => $service->state->output
+ )
+ ) ?>
+ </td>
+ <?php endforeach ?>
+ <?php
+ $horizontalTotalItems = $this->horizontalPaginator->count();
+ $horizontalItemsPerPage = $this->horizontalPaginator->getLimit() ?? $horizontalTotalItems;
+ $horizontalTotalPages = ceil($horizontalTotalItems / $horizontalItemsPerPage);
+
+ $verticalTotalItems = $this->verticalPaginator->count();
+ $verticalItemsPerPage = $this->verticalPaginator->getLimit() ?? $verticalTotalItems;
+ $verticalTotalPages = ceil($verticalTotalItems / $verticalItemsPerPage);
+
+ if (! $this->compact && $horizontalTotalPages > 1): ?>
+ <td>
+ <?php $expandLink = $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => ($horizontalItemsPerPage + 20)
+ . ','
+ . $verticalItemsPerPage
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ <?= ++$i === (int) ceil(count($pivotHeader['rows']) / 2) ? $expandLink : '' ?>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ <?php if (! $this->compact && $verticalTotalPages > 1): ?>
+ <tr>
+ <td colspan="<?= count($pivotHeader['cols']) + 1?>" class="service-grid-table-more">
+ <?php echo $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => $horizontalItemsPerPage
+ . ','
+ . ($verticalItemsPerPage + 20)
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ </td>
+ </tr>
+ <?php endif ?>
+ </tbody>
+</table>
+</div>
diff --git a/application/views/scripts/services/grid.phtml b/application/views/scripts/services/grid.phtml
new file mode 100644
index 0000000..f00ce8e
--- /dev/null
+++ b/application/views/scripts/services/grid.phtml
@@ -0,0 +1,150 @@
+<?php
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Web\Url;
+
+if (! $this->compact): ?>
+ <?= $this->controls ?>
+<?php endif ?>
+<div class="content" data-base-target="_next" id="<?= $this->protectId('content') ?>">
+<?php if (empty($pivotData)): ?>
+ <div class="item-list">
+ <div class="empty-state-bar"><?= $this->translate('No services found matching the filter.') ?></div>
+ </div>
+</div>
+<?php return; endif;
+$hostFilter = Filter::matchAny();
+foreach ($pivotData as $hostName => $_) {
+ $hostFilter->orFilter(Filter::where('host.name', $hostName));
+}
+?>
+ <table class="service-grid-table">
+ <thead>
+ <tr>
+ <th><?= $this->partial(
+ 'joystickPagination-icingadb.phtml',
+ 'default',
+ array(
+ 'flippable' => true,
+ 'baseUrl' => $baseUrl,
+ 'xAxisPaginator' => $horizontalPaginator,
+ 'yAxisPaginator' => $verticalPaginator
+ )
+ ); ?></th>
+ <?php foreach ($pivotHeader['cols'] as $serviceDescription => $serviceDisplayName):?>
+ <th class="rotate-45"><div><span><?= $this->qlink(
+ $this->ellipsis($serviceDisplayName, 24),
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($hostFilter, Filter::where('service.name', $serviceDescription))
+ ),
+ null,
+ array('title' => sprintf(
+ $this->translate('List all services with the name "%s" on all reported hosts'),
+ $serviceDisplayName
+ )),
+ false
+ ) ?></span></div></th>
+ <?php endforeach ?>
+ </tr>
+ </thead>
+ <tbody>
+
+ <?php $i = 0 ?>
+ <?php foreach ($pivotHeader['rows'] as $hostName => $hostDisplayName): ?>
+ <tr>
+ <th><?php
+ $serviceFilter = Filter::matchAny();
+ foreach ($pivotData[$hostName] as $serviceName => $_) {
+ $serviceFilter->orFilter(Filter::where('service.name', $serviceName));
+ }
+ echo $this->qlink(
+ $hostDisplayName,
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($serviceFilter, Filter::where('host.name', $hostName))
+ ),
+ null,
+ array('title' => sprintf($this->translate('List all reported services on host %s'), $hostDisplayName))
+ );
+ ?></th>
+ <?php foreach (array_keys($pivotHeader['cols']) as $serviceDescription): ?>
+ <td>
+ <?php
+ $service = $pivotData[$hostName][$serviceDescription];
+ if ($service === null): ?>
+ <span aria-hidden="true">&middot;</span>
+ <?php continue; endif ?>
+ <?php
+ $ariaDescribedById = $this->protectId($service->host_name . '_' . $service->name . '_desc') ?>
+ <span class="sr-only" id="<?= $ariaDescribedById ?>">
+ <?= $this->escape($service->state->output) ?>
+ </span>
+ <?= $this->qlink(
+ '',
+ 'icingadb/service',
+ array(
+ 'host.name' => $hostName,
+ 'name' => $serviceDescription
+ ),
+ array(
+ 'aria-describedby' => $ariaDescribedById,
+ 'aria-label' => sprintf(
+ $this->translate('Show detailed information for service %s on host %s'),
+ $service->display_name,
+ $service->host_display_name
+ ),
+ 'class' => 'service-grid-link state-' . $service->state->getStateText() . ($service->state->is_handled ? ' handled' : ''),
+ 'title' => $service->state->output
+ )
+ ) ?>
+ </td>
+ <?php endforeach ?>
+ <?php
+ $horizontalTotalItems = $this->horizontalPaginator->count();
+ $horizontalItemsPerPage = $this->horizontalPaginator->getLimit() ?? $horizontalTotalItems;
+ $horizontalTotalPages = ceil($horizontalTotalItems / $horizontalItemsPerPage);
+
+ $verticalTotalItems = $this->verticalPaginator->count();
+ $verticalItemsPerPage = $this->verticalPaginator->getLimit() ?? $verticalTotalItems;
+ $verticalTotalPages = ceil($verticalTotalItems / $verticalItemsPerPage);
+
+ if (! $this->compact && $horizontalTotalPages > 1): ?>
+ <td>
+ <?php $expandLink = $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => (
+ $horizontalItemsPerPage + 20) . ','
+ . $verticalItemsPerPage
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ <?= ++$i === (int) (count($pivotHeader['rows']) / 2) ? $expandLink : '' ?>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ <?php if (! $this->compact && $verticalTotalPages > 1): ?>
+ <tr>
+ <td colspan="<?= count($pivotHeader['cols']) + 1?>" class="service-grid-table-more">
+ <?php echo $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => $horizontalItemsPerPage . ',' .
+ ($verticalItemsPerPage + 20)
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ </td>
+ </tr>
+ <?php endif ?>
+ </tbody>
+ </table>
+</div>