diff options
Diffstat (limited to '')
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> </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> </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> </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> </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> </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">·</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">·</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> |