Merge pull request #4355 from Icinga/health-endpoint

Health endpoint
This commit is contained in:
Johannes Meyer 2021-05-17 13:11:51 +02:00 committed by GitHub
commit 0da4a11d91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 583 additions and 2 deletions

View File

@ -0,0 +1,65 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Controllers;
use Icinga\Application\Hook\HealthHook;
use Icinga\Web\View\AppHealth;
use Icinga\Web\Widget\Tabextension\OutputFormat;
use ipl\Html\Html;
use ipl\Html\HtmlString;
use ipl\Web\Compat\CompatController;
class HealthController extends CompatController
{
public function indexAction()
{
$query = HealthHook::collectHealthData()
->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();
}
}

View File

@ -0,0 +1,222 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Application\Hook;
use Exception;
use Icinga\Application\Hook;
use Icinga\Application\Logger;
use Icinga\Data\DataArray\ArrayDatasource;
use Icinga\Exception\IcingaException;
use ipl\Web\Url;
use LogicException;
abstract class HealthHook
{
/** @var int */
const STATE_OK = 0;
/** @var int */
const STATE_WARNING = 1;
/** @var int */
const STATE_CRITICAL = 2;
/** @var int */
const STATE_UNKNOWN = 3;
/** @var int The overall state */
protected $state;
/** @var string Message describing the overall state */
protected $message;
/** @var array Available metrics */
protected $metrics;
/** @var Url Url to a graphical representation of the available metrics */
protected $url;
/**
* Get overall state
*
* @return int
*/
public function getState()
{
return $this->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();
}

View File

@ -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
]
]
]);

View File

@ -0,0 +1,44 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Web\Navigation\Renderer;
use Icinga\Application\Hook\HealthHook;
class HealthNavigationRenderer extends BadgeNavigationItemRenderer
{
public function getCount()
{
$count = 0;
$title = null;
$worstState = null;
foreach (HealthHook::collectHealthData()->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;
}
}

View File

@ -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'
];
/**

View File

@ -0,0 +1,91 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Web\View;
use Icinga\Application\Hook\HealthHook;
use ipl\Html\FormattedString;
use ipl\Html\HtmlElement;
use ipl\Html\Table;
use ipl\Web\Common\BaseTarget;
use ipl\Web\Widget\Link;
use Traversable;
class AppHealth extends Table
{
use BaseTarget;
protected $defaultAttributes = ['class' => ['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', '<check> by <module> is <state-text>'),
$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');
}
}
}

View File

@ -0,0 +1,81 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Module\Monitoring\ProvidedHook;
use Icinga\Application\Hook\HealthHook;
use Icinga\Date\DateFormatter;
use Icinga\Module\Monitoring\Backend;
use ipl\Web\Url;
class Health extends HealthHook
{
/** @var object */
protected $programStatus;
public function getName()
{
return 'Icinga';
}
public function getUrl()
{
return Url::fromPath('monitoring/health/info');
}
public function checkHealth()
{
$backendName = Backend::instance()->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;
}
}

View File

@ -4,4 +4,5 @@
/** @var $this \Icinga\Application\Modules\Module */
$this->provideHook('ApplicationState');
$this->provideHook('Health');
$this->provideHook('X509/Sni');

View File

@ -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;
}
}