Merge pull request #3516 from Icinga/feature/application-state-hook

Application state hook
This commit is contained in:
Eric Lippmann 2018-07-10 09:14:26 +02:00 committed by GitHub
commit b88c6b0a6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 475 additions and 29 deletions

View File

@ -3,11 +3,12 @@
namespace Icinga\Controllers; namespace Icinga\Controllers;
use Icinga\Application\Icinga; use Icinga\Forms\AcknowledgeApplicationStateMessageForm;
use Icinga\Web\Announcement\AnnouncementCookie; use Icinga\Web\Announcement\AnnouncementCookie;
use Icinga\Web\Announcement\AnnouncementIniRepository; use Icinga\Web\Announcement\AnnouncementIniRepository;
use Icinga\Web\Controller; use Icinga\Web\Controller;
use Icinga\Web\Session; use Icinga\Web\Session;
use Icinga\Web\Widget;
/** /**
* @TODO(el): https://dev.icinga.com/issues/10646 * @TODO(el): https://dev.icinga.com/issues/10646
@ -16,10 +17,14 @@ class ApplicationStateController extends Controller
{ {
protected $requiresAuthentication = false; protected $requiresAuthentication = false;
public function init()
{
$this->_helper->layout->disableLayout();
$this->_helper->viewRenderer->setNoRender(true);
}
public function indexAction() public function indexAction()
{ {
$this->_helper->layout()->disableLayout();
if ($this->Auth()->isAuthenticated()) { if ($this->Auth()->isAuthenticated()) {
if (isset($_COOKIE['icingaweb2-session'])) { if (isset($_COOKIE['icingaweb2-session'])) {
$last = (int) $_COOKIE['icingaweb2-session']; $last = (int) $_COOKIE['icingaweb2-session'];
@ -59,6 +64,31 @@ class ApplicationStateController extends Controller
} }
} }
$this->setAutorefreshInterval(60);
}
public function summaryAction()
{
if ($this->Auth()->isAuthenticated()) {
$this->getResponse()->setBody((string) Widget::create('ApplicationStateMessages'));
}
$this->setAutorefreshInterval(60);
}
public function acknowledgeMessageAction()
{
if (! $this->Auth()->isAuthenticated()) {
$this->getResponse()
->setHttpResponseCode(401)
->sendHeaders();
exit;
}
$this->assertHttpMethod('POST');
$this->getResponse()->setHeader('X-Icinga-Container', 'ignore', true); $this->getResponse()->setHeader('X-Icinga-Container', 'ignore', true);
(new AcknowledgeApplicationStateMessageForm())->handleRequest();
} }
} }

View File

@ -0,0 +1,75 @@
<?php
/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
namespace Icinga\Forms;
use Icinga\Application\Hook\ApplicationStateHook;
use Icinga\Web\ApplicationStateCookie;
use Icinga\Web\Form;
use Icinga\Web\Url;
class AcknowledgeApplicationStateMessageForm extends Form
{
public function init()
{
$this->setAction(Url::fromPath('application-state/acknowledge-message'));
$this->setAttrib('class', 'form-inline application-state-acknowledge-message-control');
$this->setRedirectUrl('application-state/summary');
}
public function addSubmitButton()
{
$this->addElement(
'button',
'btn_submit',
[
'class' => 'link-button spinner',
'decorators' => [
'ViewHelper',
['HtmlTag', ['tag' => 'div', 'class' => 'control-group form-controls']]
],
'escape' => false,
'ignore' => true,
'label' => $this->getView()->icon('cancel'),
'title' => $this->translate('Acknowledge message'),
'type' => 'submit'
]
);
return $this;
}
public function createElements(array $formData = [])
{
$this->addElements(
[
[
'hidden',
'id',
[
'required' => true,
'validators' => ['NotEmpty'],
'decorators' => ['ViewHelper']
]
]
]
);
return $this;
}
public function onSuccess()
{
$cookie = new ApplicationStateCookie();
$ack = $cookie->getAcknowledgedMessages();
$ack[] = $this->getValue('id');
$active = ApplicationStateHook::getAllMessages();
$cookie->setAcknowledgedMessages(array_keys(array_intersect_key($active, array_flip($ack))));
$this->getResponse()->setCookie($cookie);
return true;
}
}

View File

@ -43,6 +43,20 @@ class ApplicationConfigForm extends Form
) )
); );
$this->addElement(
'checkbox',
'global_show_application_state_messages',
array(
'required' => true,
'value' => true,
'label' => $this->translate('Show Application State Messages'),
'description' => $this->translate(
"Set whether to show application state messages."
. " This can also be set in a user's preferences."
)
)
);
$this->addElement( $this->addElement(
'text', 'text',
'global_module_path', 'global_module_path',

View File

@ -233,6 +233,23 @@ class PreferenceForm extends Form
) )
); );
$this->addElement(
'select',
'show_application_state_messages',
array(
'required' => true,
'label' => $this->translate('Show application state messages'),
'description' => $this->translate('Whether to show application state messages.'),
'multiOptions' => [
'system' => (bool) Config::app()->get('global', 'show_application_state_messages', true)
? $this->translate('System (Yes)')
: $this->translate('System (No)'),
1 => $this->translate('Yes'),
0 => $this->translate('No')],
'value' => 'system'
)
);
if (Auth::getInstance()->hasPermission('application/stacktraces')) { if (Auth::getInstance()->hasPermission('application/stacktraces')) {
$this->addElement( $this->addElement(
'checkbox', 'checkbox',

View File

@ -64,4 +64,5 @@ if ($this->layout()->inlineLayout) {
} }
} }
?></ul> ?></ul>
<div id="application-state-summary" class="container" data-icinga-url="<?= $this->url('application-state/summary') ?>" data-last-update="-1" data-icinga-refresh="60"></div>
</div> </div>

View File

@ -0,0 +1,90 @@
<?php
/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
namespace Icinga\Application\Hook;
use Icinga\Application\Hook;
use Icinga\Application\Logger;
/**
* Application state hook base class
*/
abstract class ApplicationStateHook
{
const ERROR = 'error';
private $messages = [];
final public function hasMessages()
{
return ! empty($this->messages);
}
final public function getMessages()
{
return $this->messages;
}
/**
* Add an error message
*
* The timestamp of the message is used for deduplication and thus must refer to the time when the error first
* occurred. Don't use {@link time()} here!
*
* @param string $id ID of the message. The ID must be prefixed with the module name
* @param int $timestamp Timestamp when the error first occurred
* @param string $message Error message
*
* @return $this
*/
final public function addError($id, $timestamp, $message)
{
$id = trim($id);
$timestamp = (int) $timestamp;
if (! strlen($id)) {
throw new \InvalidArgumentException('ID expected.');
}
if (! $timestamp) {
throw new \InvalidArgumentException('Timestamp expected.');
}
$this->messages[sha1($id . $timestamp)] = [self::ERROR, $timestamp, $message];
return $this;
}
/**
* Override this method in order to provide application state messages
*/
abstract public function collectMessages();
final public static function getAllMessages()
{
$messages = [];
if (! Hook::has('ApplicationState')) {
return $messages;
}
foreach (Hook::all('ApplicationState') as $hook) {
/** @var self $hook */
try {
$hook->collectMessages();
} catch (\Exception $e) {
Logger::error(
"Failed to collect messages from hook '%s'. An error occurred: %s",
get_class($hook),
$e
);
}
if ($hook->hasMessages()) {
$messages += $hook->getMessages();
}
}
return $messages;
}
}

View File

@ -0,0 +1,74 @@
<?php
/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
namespace Icinga\Web;
use Icinga\Application\Logger;
use Icinga\Authentication\Auth;
use Icinga\Exception\Json\JsonDecodeException;
use Icinga\Util\Json;
/**
* Handle acknowledged application state messages via cookie
*/
class ApplicationStateCookie extends Cookie
{
/** @var array */
protected $acknowledgedMessages = [];
public function __construct()
{
parent::__construct('icingaweb2-application-state');
$this->setExpire(2147483648);
if (isset($_COOKIE['icingaweb2-application-state'])) {
try {
$cookie = Json::decode($_COOKIE['icingaweb2-application-state'], true);
} catch (JsonDecodeException $e) {
Logger::error(
"Can't decode the application state cookie of user '%s'. An error occurred: %s",
Auth::getInstance()->getUser()->getUsername(),
$e
);
return;
}
if (isset($cookie['acknowledged-messages'])) {
$this->setAcknowledgedMessages($cookie['acknowledged-messages']);
}
}
}
/**
* Get the acknowledged messages
*
* @return array
*/
public function getAcknowledgedMessages()
{
return $this->acknowledgedMessages;
}
/**
* Set the acknowledged messages
*
* @param array $acknowledged
*
* @return $this
*/
public function setAcknowledgedMessages(array $acknowledged)
{
$this->acknowledgedMessages = $acknowledged;
return $this;
}
public function getValue()
{
return Json::encode([
'acknowledged-messages' => $this->getAcknowledgedMessages()
]);
}
}

View File

@ -0,0 +1,78 @@
<?php
/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
namespace Icinga\Web\Widget;
use Icinga\Application\Config;
use Icinga\Application\Hook\ApplicationStateHook;
use Icinga\Authentication\Auth;
use Icinga\Forms\AcknowledgeApplicationStateMessageForm;
use Icinga\Web\ApplicationStateCookie;
use Icinga\Web\Helper\HtmlPurifier;
/**
* Render application state messages
*/
class ApplicationStateMessages extends AbstractWidget
{
protected function getMessages()
{
$cookie = new ApplicationStateCookie();
$acked = array_flip($cookie->getAcknowledgedMessages());
$messages = ApplicationStateHook::getAllMessages();
$active = array_diff_key($messages, $acked);
return $active;
}
protected function getPurifier()
{
return new HtmlPurifier(['HTML.Allowed' => 'b,a[href|target],i,*[class]']);
}
public function render()
{
$enabled = Auth::getInstance()
->getUser()
->getPreferences()
->getValue('icingaweb', 'show_application_state_messages', 'system');
if ($enabled === 'system') {
$enabled = Config::app()->get('global', 'show_application_state_messages', true);
}
if (! (bool) $enabled) {
return '<div style="display: none;"></div>';
}
$active = $this->getMessages();
if (empty($active)) {
// Force container update on XHR
return '<div style="display: none;"></div>';
}
$purifier = $this->getPurifier();
$html = '<div>';
reset($active);
$id = key($active);
$spec = current($active);
$message = array_pop($spec); // We don't use state and timestamp here
$ackForm = new AcknowledgeApplicationStateMessageForm();
$ackForm->populate(['id' => $id]);
$html .= $purifier->purify($message) . $ackForm;
$html .= '</div>';
return $html;
}
}

View File

@ -0,0 +1,32 @@
<?php
/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Monitoring\ProvidedHook;
use Icinga\Application\Hook\ApplicationStateHook;
use Icinga\Module\Monitoring\Backend\MonitoringBackend;
class ApplicationState extends ApplicationStateHook
{
public function collectMessages()
{
$backend = MonitoringBackend::instance();
$programStatus = $backend
->select()
->from(
'programstatus',
['is_currently_running', 'status_update_time']
)
->fetchRow();
if ($programStatus === false || ! (bool) $programStatus->is_currently_running) {
$message = sprintf(
mt('monitoring', "Monitoring backend '%s' is not running."),
$backend->getName()
);
$this->addError('monitoring/backend-down', $programStatus->status_update_time, $message);
}
}
}

View File

@ -6,45 +6,72 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
> li:before { > li {
border-bottom: 1px solid @gray-lighter;
line-height: 1.5em;
padding: 0.5em 1em 0.5em 3em;
position: relative;
&:before {
color: @icinga-blue; color: @icinga-blue;
content: "\e811"; content: "\e811";
font-family: 'ifont'; font-family: 'ifont';
left: 1em;
margin-top: -1em;
padding: 0.3em;
position: absolute; position: absolute;
text-align: center; left: 1.25em;
top: 50%;
} }
> li { &:last-child {
border-bottom: 1px solid @gray-lighter; border-bottom: none;
padding: 1em 3em; }
position: relative;
a { a {
color: @icinga-blue; color: @icinga-blue;
} }
}
> li .message { .message {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
} }
> li:last-child {
border-bottom: none;
} }
}
.acknowledge-announcement-control { .acknowledge-announcement-control,
.application-state-acknowledge-message-control {
background: none; background: none;
border: none; border: none;
display: block; display: block;
margin-top: -0.6em; margin-top: -0.75em;
position: absolute; position: absolute;
right: 1em; right: 1em;
top: 50%; top: 50%;
}
.application-state-acknowledge-message-control .link-button {
color: #fff;
&:hover .icon-cancel {
color: @icinga-blue;
}
}
#application-state-summary > div {
background-color: @color-critical;
color: #fff;
line-height: 1.5em;
padding: 0.5em 1em 0.5em 3em;
width: 100%;
position: relative;
&:before {
content: "\e84d";
font-family: 'ifont';
position: absolute;
left: 1.25em;
} }
} }

View File

@ -10,6 +10,7 @@
var ApplicationState = function (icinga) { var ApplicationState = function (icinga) {
Icinga.EventListener.call(this, icinga); Icinga.EventListener.call(this, icinga);
this.on('rendered', this.onRendered, this); this.on('rendered', this.onRendered, this);
this.on('rendered', '#application-state-summary', this.onSummaryRendered, this);
this.icinga = icinga; this.icinga = icinga;
}; };
@ -31,6 +32,13 @@
} }
}; };
ApplicationState.prototype.onSummaryRendered = function(e) {
var height = $(this).height();
$('#sidebar').css('bottom', height);
$('#main').css('bottom', height);
};
Icinga.Behaviors.ApplicationState = ApplicationState; Icinga.Behaviors.ApplicationState = ApplicationState;
})(Icinga, jQuery); })(Icinga, jQuery);