Introduce health endpoint /health[/checks]
This commit is contained in:
parent
1a2bba0fd5
commit
5e382dcfa9
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -52,7 +52,8 @@ class StyleSheet
|
||||||
'css/icinga/print.less',
|
'css/icinga/print.less',
|
||||||
'css/icinga/responsive.less',
|
'css/icinga/responsive.less',
|
||||||
'css/icinga/modal.less',
|
'css/icinga/modal.less',
|
||||||
'css/icinga/audit.less'
|
'css/icinga/audit.less',
|
||||||
|
'css/icinga/health.less'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue