From 5e382dcfa98c3594beb7b9780cb9e3ce1b46299c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 20 Apr 2021 13:04:21 +0200 Subject: [PATCH 1/3] Introduce health endpoint /health[/checks] --- application/controllers/HealthController.php | 65 +++++ .../Icinga/Application/Hook/HealthHook.php | 222 ++++++++++++++++++ library/Icinga/Web/StyleSheet.php | 3 +- library/Icinga/Web/View/AppHealth.php | 91 +++++++ public/css/icinga/health.less | 68 ++++++ 5 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 application/controllers/HealthController.php create mode 100644 library/Icinga/Application/Hook/HealthHook.php create mode 100644 library/Icinga/Web/View/AppHealth.php create mode 100644 public/css/icinga/health.less diff --git a/application/controllers/HealthController.php b/application/controllers/HealthController.php new file mode 100644 index 000000000..55a20afe5 --- /dev/null +++ b/application/controllers/HealthController.php @@ -0,0 +1,65 @@ +select(); + + $this->setupSortControl( + [ + 'module' => $this->translate('Module'), + 'name' => $this->translate('Name'), + 'state' => $this->translate('State') + ], + $query, + ['state' => 'desc'] + ); + $this->setupLimitControl(); + $this->setupPaginationControl($query); + $this->setupFilterControl($query, [ + 'module' => $this->translate('Module'), + 'name' => $this->translate('Name'), + 'state' => $this->translate('State'), + 'message' => $this->translate('Message') + ], ['name'], ['format']); + + $this->getTabs()->extend(new OutputFormat(['csv'])); + $this->handleFormatRequest($query); + + $this->addControl(HtmlString::create((string) $this->view->paginator)); + $this->addControl(Html::tag('div', ['class' => 'sort-controls-container'], [ + HtmlString::create((string) $this->view->limiter), + HtmlString::create((string) $this->view->sortBox) + ])); + $this->addControl(HtmlString::create((string) $this->view->filterEditor)); + + $this->setTitle(t('Health')); + $this->setAutorefreshInterval(10); + $this->addContent(new AppHealth($query)); + } + + protected function handleFormatRequest($query) + { + $formatJson = $this->params->get('format') === 'json'; + if (! $formatJson && ! $this->getRequest()->isApiRequest()) { + return; + } + + $this->getResponse() + ->json() + ->setSuccessData($query->fetchAll()) + ->sendResponse(); + } +} diff --git a/library/Icinga/Application/Hook/HealthHook.php b/library/Icinga/Application/Hook/HealthHook.php new file mode 100644 index 000000000..f6420b5ac --- /dev/null +++ b/library/Icinga/Application/Hook/HealthHook.php @@ -0,0 +1,222 @@ +state; + } + + /** + * Set overall state + * + * @param int $state + * + * @return $this + */ + public function setState($state) + { + $this->state = $state; + + return $this; + } + + /** + * Get the message describing the overall state + * + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Set the message describing the overall state + * + * @param string $message + * + * @return $this + */ + public function setMessage($message) + { + $this->message = $message; + + return $this; + } + + /** + * Get available metrics + * + * @return array + */ + public function getMetrics() + { + return $this->metrics; + } + + /** + * Set available metrics + * + * @param array $metrics + * + * @return $this + */ + public function setMetrics(array $metrics) + { + $this->metrics = $metrics; + + return $this; + } + + /** + * Get the url to a graphical representation of the available metrics + * + * @return Url + */ + public function getUrl() + { + return $this->url; + } + + /** + * Set the url to a graphical representation of the available metrics + * + * @param Url $url + * + * @return $this + */ + public function setUrl(Url $url) + { + $this->url = $url; + + return $this; + } + + /** + * Collect available health data from hooks + * + * @return ArrayDatasource + */ + final public static function collectHealthData() + { + $checks = []; + foreach (Hook::all('health') as $hook) { + /** @var self $hook */ + + try { + $hook->checkHealth(); + $url = $hook->getUrl(); + $state = $hook->getState(); + $message = $hook->getMessage(); + $metrics = $hook->getMetrics(); + } catch (Exception $e) { + Logger::error('Failed to check health: %s', $e); + + $state = self::STATE_UNKNOWN; + $message = IcingaException::describe($e); + $metrics = null; + $url = null; + } + + $checks[] = (object) [ + 'module' => $hook->getModuleName(), + 'name' => $hook->getName(), + 'url' => $url ? $url->getAbsoluteUrl() : null, + 'state' => $state, + 'message' => $message, + 'metrics' => (object) $metrics + ]; + } + + return (new ArrayDatasource($checks)) + ->setKeyColumn('name'); + } + + /** + * Get the name of the hook + * + * Only used in API responses to differentiate it from other hooks of the same module. + * + * @return string + */ + public function getName() + { + $classPath = get_class($this); + $parts = explode('\\', $classPath); + $className = array_pop($parts); + + if (substr($className, -4) === 'Hook') { + $className = substr($className, 1, -4); + } + + return strtolower($className[0]) . substr($className, 1); + } + + /** + * Get the name of the module providing this hook + * + * @return string + * + * @throws LogicException + */ + public function getModuleName() + { + $classPath = get_class($this); + if (substr($classPath, 0, 14) !== 'Icinga\\Module\\') { + throw new LogicException('Not a module hook'); + } + + $withoutPrefix = substr($classPath, 14); + return strtolower(substr($withoutPrefix, 0, strpos($withoutPrefix, '\\'))); + } + + /** + * Check health + * + * Implement this method and set the overall state, message, url and metrics. + * + * @return void + */ + abstract public function checkHealth(); +} diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index dfa002c2f..71c139e92 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -52,7 +52,8 @@ class StyleSheet 'css/icinga/print.less', 'css/icinga/responsive.less', 'css/icinga/modal.less', - 'css/icinga/audit.less' + 'css/icinga/audit.less', + 'css/icinga/health.less' ]; /** diff --git a/library/Icinga/Web/View/AppHealth.php b/library/Icinga/Web/View/AppHealth.php new file mode 100644 index 000000000..e52eb8699 --- /dev/null +++ b/library/Icinga/Web/View/AppHealth.php @@ -0,0 +1,91 @@ + ['app-health', 'common-table', 'table-row-selectable']]; + + /** @var Traversable */ + protected $data; + + public function __construct(Traversable $data) + { + $this->data = $data; + + $this->setBaseTarget('_next'); + } + + protected function assemble() + { + foreach ($this->data as $row) { + $this->add(Table::tr([ + Table::th(new HtmlElement('span', ['class' => [ + 'ball', + 'ball-size-xl', + $this->getStateClass($row->state) + ]])), + Table::td([ + new HtmlElement('header', null, [ + FormattedString::create( + t('%s by %s is %s', ' by is '), + $row->url + ? new Link(new HtmlElement('span', null, $row->name), $row->url) + : new HtmlElement('span', null, $row->name), + new HtmlElement('span', null, $row->module), + new HtmlElement('span', null, $this->getStateText($row->state)) + ) + ]), + new HtmlElement('section', null, $row->message) + ]) + ])); + } + } + + protected function getStateClass($state) + { + if ($state === null) { + $state = HealthHook::STATE_UNKNOWN; + } + + switch ($state) { + case HealthHook::STATE_OK: + return 'state-ok'; + case HealthHook::STATE_WARNING: + return 'state-warning'; + case HealthHook::STATE_CRITICAL: + return 'state-critical'; + case HealthHook::STATE_UNKNOWN: + return 'state-unknown'; + } + } + + protected function getStateText($state) + { + if ($state === null) { + $state = t('UNKOWN'); + } + + switch ($state) { + case HealthHook::STATE_OK: + return t('OK'); + case HealthHook::STATE_WARNING: + return t('WARNING'); + case HealthHook::STATE_CRITICAL: + return t('CRITICAL'); + case HealthHook::STATE_UNKNOWN: + return t('UNKNOWN'); + } + } +} diff --git a/public/css/icinga/health.less b/public/css/icinga/health.less new file mode 100644 index 000000000..402e63a41 --- /dev/null +++ b/public/css/icinga/health.less @@ -0,0 +1,68 @@ +// Style + +.app-health { + header { + color: @text-color-light; + + span { + color: @text-color; + } + } + + span { + &.state-ok { + background-color: @color-ok; + } + + &.state-warning { + background-color: @color-warning; + } + + &.state-critical { + background-color: @color-critical; + } + + &.state-unknown { + background-color: @color-unknown; + } + } + + a { + font-weight: bold; + } + + tbody tr, tr.active { + border: none; + } + + tr:not(:last-child) td { + border: 0 solid @gray-light; + border-bottom-width: 1px; + } + + section { + color: @text-color-light; + font-family: @font-family-fixed; + } +} + +// Layout + +.app-health { + th { + width: 2.5em; + padding: .5em 1em 0 .5em; + vertical-align: top; + } + + td { + padding: .5em 0; + } + + section { + margin-top: .25em; + height: 3em; + overflow: hidden; + word-break: break-word; + } +} From 0d2bf1ae33ccd1204a63b5bda0d48c5c4594fcc1 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 20 Apr 2021 13:04:55 +0200 Subject: [PATCH 2/3] Menu: Integrate health endpoint --- library/Icinga/Web/Menu.php | 10 ++++- .../Renderer/HealthNavigationRenderer.php | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php diff --git a/library/Icinga/Web/Menu.php b/library/Icinga/Web/Menu.php index 5f822fa2b..7a8b39c90 100644 --- a/library/Icinga/Web/Menu.php +++ b/library/Icinga/Web/Menu.php @@ -48,12 +48,20 @@ class Menu extends Navigation 'url' => 'about', 'priority' => 700 ], + 'health' => [ + 'icon' => 'eye', + 'description' => t('Open health overview'), + 'label' => t('Health'), + 'url' => 'health', + 'priority' => 710, + 'renderer' => 'HealthNavigationRenderer' + ], 'announcements' => [ 'icon' => 'megaphone', 'description' => t('List announcements'), 'label' => t('Announcements'), 'url' => 'announcements', - 'priority' => 710 + 'priority' => 720 ] ] ]); diff --git a/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php new file mode 100644 index 000000000..577895bf2 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php @@ -0,0 +1,44 @@ +select() as $result) { + if ($worstState === null || $result->state > $worstState) { + $worstState = $result->state; + $title = $result->message; + $count = 1; + } elseif ($worstState === $result->state) { + $count++; + } + } + + switch ($worstState) { + case HealthHook::STATE_OK: + $count = 0; + break; + case HealthHook::STATE_WARNING: + $this->state = self::STATE_WARNING; + break; + case HealthHook::STATE_CRITICAL: + $this->state = self::STATE_CRITICAL; + break; + case HealthHook::STATE_UNKNOWN: + $this->state = self::STATE_UNKNOWN; + break; + } + + $this->title = $title; + + return $count; + } +} From 84949f214ee3022a668f1506a9f120d016ee9de7 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 20 Apr 2021 13:05:22 +0200 Subject: [PATCH 3/3] monitoring: Provide health hook --- .../Monitoring/ProvidedHook/Health.php | 81 +++++++++++++++++++ modules/monitoring/run.php | 1 + 2 files changed, 82 insertions(+) create mode 100644 modules/monitoring/library/Monitoring/ProvidedHook/Health.php diff --git a/modules/monitoring/library/Monitoring/ProvidedHook/Health.php b/modules/monitoring/library/Monitoring/ProvidedHook/Health.php new file mode 100644 index 000000000..da32a23d3 --- /dev/null +++ b/modules/monitoring/library/Monitoring/ProvidedHook/Health.php @@ -0,0 +1,81 @@ +getName(); + $programStatus = $this->getProgramStatus(); + if ($programStatus === false) { + $this->setState(self::STATE_UNKNOWN); + $this->setMessage(sprintf(t('%s is currently not up and running'), $backendName)); + return; + } + + if ($programStatus->is_currently_running) { + $this->setState(self::STATE_OK); + $this->setMessage(sprintf( + t( + '%1$s has been up and running with PID %2$d %3$s', + 'Last format parameter represents the time running' + ), + $backendName, + $programStatus->process_id, + DateFormatter::timeSince($programStatus->program_start_time) + )); + } else { + $this->setState(self::STATE_CRITICAL); + $this->setMessage(sprintf(t('Backend %s is not running'), $backendName)); + } + + $this->setMetrics((array) $programStatus); + } + + protected function getProgramStatus() + { + if ($this->programStatus === null) { + $this->programStatus = Backend::instance()->select() + ->from('programstatus', [ + 'program_version', + 'status_update_time', + 'program_start_time', + 'program_end_time', + 'endpoint_name', + 'is_currently_running', + 'process_id', + 'last_command_check', + 'last_log_rotation', + 'notifications_enabled', + 'active_service_checks_enabled', + 'active_host_checks_enabled', + 'event_handlers_enabled', + 'flap_detection_enabled', + 'process_performance_data' + ]) + ->fetchRow(); + } + + return $this->programStatus; + } +} diff --git a/modules/monitoring/run.php b/modules/monitoring/run.php index f1f48ddbe..6fe492178 100644 --- a/modules/monitoring/run.php +++ b/modules/monitoring/run.php @@ -4,4 +4,5 @@ /** @var $this \Icinga\Application\Modules\Module */ $this->provideHook('ApplicationState'); +$this->provideHook('Health'); $this->provideHook('X509/Sni');