commit
0da4a11d91
|
@ -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();
|
||||
}
|
|
@ -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
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,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;
|
||||
}
|
||||
}
|
|
@ -4,4 +4,5 @@
|
|||
/** @var $this \Icinga\Application\Modules\Module */
|
||||
|
||||
$this->provideHook('ApplicationState');
|
||||
$this->provideHook('Health');
|
||||
$this->provideHook('X509/Sni');
|
||||
|
|
|
@ -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