Implement announcements

refs #11198
This commit is contained in:
Eric Lippmann 2016-11-29 15:12:33 +01:00
parent 5a9540aa95
commit 39c7451664
17 changed files with 957 additions and 5 deletions

View File

@ -0,0 +1,100 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Controllers;
use Icinga\Exception\NotFoundError;
use Icinga\Forms\Announcement\AcknowledgeAnnouncementForm;
use Icinga\Forms\Announcement\AnnouncementForm;
use Icinga\Web\Announcement\AnnouncementIniRepository;
use Icinga\Web\Controller;
use Icinga\Web\Url;
class AnnouncementsController extends Controller
{
/**
* List all announcements
*/
public function indexAction()
{
$this->getTabs()->add(
'announcements',
array(
'active' => true,
'label' => $this->translate('Announcements'),
'title' => $this->translate('List All Announcements'),
'url' => Url::fromPath('announcements')
)
);
$repo = new AnnouncementIniRepository();
$this->view->announcements = $repo
->select(array('id', 'author', 'message', 'start', 'end'))
->order('start');
}
/**
* Create an announcement
*/
public function newAction()
{
$this->assertPermission('admin');
$form = $this->prepareForm()->add();
$form->handleRequest();
$this->renderForm($form, $this->translate('New Announcement'));
}
/**
* Update an announcement
*/
public function updateAction()
{
$this->assertPermission('admin');
$form = $this->prepareForm()->edit($this->params->getRequired('id'));
try {
$form->handleRequest();
} catch (NotFoundError $_) {
$this->httpNotFound($this->translate('Announcement not found'));
}
$this->renderForm($form, $this->translate('Update Announcement'));
}
/**
* Remove an announcement
*/
public function removeAction()
{
$this->assertPermission('admin');
$form = $this->prepareForm()->remove($this->params->getRequired('id'));
try {
$form->handleRequest();
} catch (NotFoundError $_) {
$this->httpNotFound($this->translate('Announcement not found'));
}
$this->renderForm($form, $this->translate('Remove Announcement'));
}
public function acknowledgeAction()
{
$this->assertHttpMethod('POST');
$this->getResponse()->setHeader('X-Icinga-Container', 'ignore', true);
$form = new AcknowledgeAnnouncementForm();
$form->handleRequest();
}
/**
* Assert permission admin and return a prepared RepositoryForm
*
* @return AnnouncementForm
*/
protected function prepareForm()
{
$form = new AnnouncementForm();
return $form
->setRepository(new AnnouncementIniRepository())
->setRedirectUrl(Url::fromPath('announcements'));
}
}

View File

@ -4,6 +4,8 @@
namespace Icinga\Controllers;
use Icinga\Application\Icinga;
use Icinga\Web\Announcement\AnnouncementCookie;
use Icinga\Web\Announcement\AnnouncementIniRepository;
use Icinga\Web\Controller;
use Icinga\Web\Session;
@ -14,6 +16,7 @@ class ApplicationStateController extends Controller
{
public function indexAction()
{
$this->_helper->layout()->disableLayout();
if (isset($_COOKIE['icingaweb2-session'])) {
$last = (int) $_COOKIE['icingaweb2-session'];
} else {
@ -34,6 +37,23 @@ class ApplicationStateController extends Controller
);
$_COOKIE['icingaweb2-session'] = $now;
}
Icinga::app()->getResponse()->setHeader('X-Icinga-Container', 'ignore', true);
$announcementCookie = new AnnouncementCookie();
$announcementRepo = new AnnouncementIniRepository();
if ($announcementCookie->getEtag() !== $announcementRepo->getEtag()) {
$announcementCookie
->setEtag($announcementRepo->getEtag())
->setNextActive($announcementRepo->findNextActive());
$this->getResponse()->setCookie($announcementCookie);
$this->getResponse()->setHeader('X-Icinga-Announcements', 'refresh', true);
} else {
$nextActive = $announcementCookie->getNextActive();
if ($nextActive && $nextActive <= $now) {
$announcementCookie->setNextActive($announcementRepo->findNextActive());
$this->getResponse()->setCookie($announcementCookie);
$this->getResponse()->setHeader('X-Icinga-Announcements', 'refresh', true);
}
}
$this->getResponse()->setHeader('X-Icinga-Container', 'ignore', true);
}
}

View File

@ -20,4 +20,9 @@ class LayoutController extends ActionController
$this->_helper->layout()->disableLayout();
$this->view->menuRenderer = Icinga::app()->getMenu()->getRenderer();
}
public function announcementsAction()
{
$this->_helper->layout()->disableLayout();
}
}

View File

@ -0,0 +1,79 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Forms\Announcement;
use Icinga\Web\Announcement\AnnouncementCookie;
use Icinga\Web\Form;
class AcknowledgeAnnouncementForm extends Form
{
/**
* {@inheritdoc}
*/
public function init()
{
$this->setAction('announcements/acknowledge');
$this->setAttrib('class', 'form-inline acknowledge-announcement-control');
$this->setRedirectUrl('layout/announcements');
}
/**
* {@inheritdoc}
*/
public function addSubmitButton()
{
$this->addElement(
'button',
'btn_submit',
array(
'class' => 'link-button spinner',
'decorators' => array(
'ViewHelper',
array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls'))
),
'escape' => false,
'ignore' => true,
'label' => $this->getView()->icon('cancel'),
'title' => $this->translate('Acknowledge this announcement'),
'type' => 'submit'
)
);
return $this;
}
/**
* {@inheritdoc}
*/
public function createElements(array $formData = array())
{
$this->addElements(
array(
array(
'hidden',
'hash',
array(
'required' => true,
'validators' => array('NotEmpty'),
'decorators' => array('ViewHelper')
)
)
)
);
return $this;
}
/**
* {@inheritdoc}
*/
public function onSuccess()
{
$cookie = new AnnouncementCookie();
$acknowledged = $cookie->getAcknowledged();
$acknowledged[] = $this->getElement('hash')->getValue();
$cookie->setAcknowledged($acknowledged);
$this->getResponse()->setCookie($cookie);
return true;
}
}

View File

@ -0,0 +1,117 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Forms\Announcement;
use Icinga\Authentication\Auth;
use Icinga\Data\Filter\Filter;
use Icinga\Forms\RepositoryForm;
/**
* Create, update and delete announcements
*/
class AnnouncementForm extends RepositoryForm
{
/**
* {@inheritDoc}
*/
protected function createInsertElements(array $formData)
{
$this->addElement(
'text',
'author',
array(
'required' => true,
'value' => Auth::getInstance()->getUser()->getUsername(),
'disabled' => true
)
);
$this->addElement(
'textarea',
'message',
array(
'required' => true,
'label' => $this->translate('Message'),
'description' => $this->translate('The message to display to users')
)
);
$this->addElement(
'dateTimePicker',
'start',
array(
'required' => true,
'label' => $this->translate('Start'),
'description' => $this->translate('The time to display the announcement from')
)
);
$this->addElement(
'dateTimePicker',
'end',
array(
'required' => true,
'label' => $this->translate('End'),
'description' => $this->translate('The time to display the announcement until')
)
);
$this->setTitle($this->translate('Create a new announcement'));
$this->setSubmitLabel($this->translate('Create'));
}
/**
* {@inheritDoc}
*/
protected function createUpdateElements(array $formData)
{
$this->createInsertElements($formData);
$this->setTitle(sprintf($this->translate('Edit announcement %s'), $this->getIdentifier()));
$this->setSubmitLabel($this->translate('Save'));
}
/**
* {@inheritDoc}
*/
protected function createDeleteElements(array $formData)
{
$this->setTitle(sprintf($this->translate('Remove announcement %s?'), $this->getIdentifier()));
$this->setSubmitLabel($this->translate('Yes'));
}
/**
* {@inheritDoc}
*/
protected function createFilter()
{
return Filter::where('id', $this->getIdentifier());
}
/**
* {@inheritDoc}
*/
protected function getInsertMessage($success)
{
return $success
? $this->translate('Announcement created')
: $this->translate('Failed to create announcement');
}
/**
* {@inheritDoc}
*/
protected function getUpdateMessage($success)
{
return $success
? $this->translate('Announcement updated')
: $this->translate('Failed to update announcement');
}
/**
* {@inheritDoc}
*/
protected function getDeleteMessage($success)
{
return $success
? $this->translate('Announcement removed')
: $this->translate('Failed to remove announcement');
}
}

View File

@ -44,6 +44,9 @@ class RoleForm extends ConfigForm
) . ' (application/stacktraces)',
'application/log' => $this->translate('Allow to view the application log')
. ' (application/log)',
'admin' => $this->translate(
'Grant admin permissions, e.g. manage announcements'
) . ' (admin)',
'config/*' => $this->translate('Allow config access') . ' (config/*)'
);

View File

@ -18,6 +18,9 @@ if ($this->layout()->autorefreshInterval) {
?>
<div id="header">
<div id="announcements" class="container">
<?= $this->widget('announcements') ?>
</div>
<div id="header-logo-container">
<?= $this->qlink(
'',

View File

@ -0,0 +1,61 @@
<?php if (! $compact): ?>
<div class="controls">
<?= $tabs ?>
</div>
<?php endif ?>
<div class="content">
<?php if ($this->hasPermission('admin')) {
echo $this->qlink(
$this->translate('Create a New Announcement') ,
'announcements/new',
null,
array(
'class' => 'button-link',
'data-base-target' => '_next',
'icon' => 'plus',
'title' => $this->translate('Create a new announcement')
)
);
} ?>
<?php if (! $announcements->hasResult()): ?>
<p><?= $this->translate('No announcements found.') ?></p>
</div>
<?php return; endif ?>
<table data-base-target="_next" class="table-row-selectable common-table">
<thead>
<tr>
<th><?= $this->translate('Author') ?></th>
<th><?= $this->translate('Message') ?></th>
<th><?= $this->translate('Start') ?></th>
<th><?= $this->translate('End') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($announcements as $announcement): /** @var object $announcement */ ?>
<?php if ($this->hasPermission('admin')): ?>
<tr href="<?= $this->href('announcements/update', array('id' => $announcement->id)) ?>">
<?php else: ?>
<tr>
<?php endif ?>
<td><?= $this->escape($announcement->author) ?></td>
<td><?= $this->ellipsis($announcement->message, 100) ?></td>
<td><?= $this->formatDateTime($announcement->start->getTimestamp()) ?></td>
<td><?= $this->formatDateTime($announcement->end->getTimestamp()) ?></td>
<?php if ($this->hasPermission('admin')): ?>
<td class="icon-col"><?= $this->qlink(
null,
'announcements/remove',
array('id' => $announcement->id),
array(
'class' => 'action-link',
'icon' => 'cancel',
'title' => $this->translate('Remove this announcement')
)
) ?></td>
<?php endif ?>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>

View File

@ -0,0 +1 @@
<?= $this->widget('announcements') ?>

View File

@ -304,7 +304,12 @@ class Web extends EmbeddedWeb
'about' => array(
'label' => t('About'),
'url' => 'about',
'priority' => 701
'priority' => 700
),
'announcements' => array(
'label' => t('Announcements'),
'url' => 'announcements',
'priority' => 710
)
)
),
@ -366,7 +371,7 @@ class Web extends EmbeddedWeb
'label' => t('Application Log'),
'url' => 'list/applicationlog',
'permission' => 'application/log',
'priority' => 710
'priority' => 900
);
}
} else {

View File

@ -0,0 +1,158 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Web;
/**
* An announcement to be displayed prominently in the web UI
*/
class Announcement
{
/**
* @var string
*/
protected $author;
/**
* @var string
*/
protected $message;
/**
* @var int
*/
protected $start;
/**
* @var int
*/
protected $end;
/**
* Hash of the message
*
* @var string|null
*/
protected $hash = null;
/**
* Announcement constructor
*
* @param array $properties
*/
public function __construct(array $properties = array())
{
foreach ($properties as $key => $value) {
$method = 'set' . ucfirst($key);
if (method_exists($this, $method)) {
$this->$method($value);
}
}
}
/**
* Get the author of the acknowledged
*
* @return string
*/
public function getAuthor()
{
return $this->author;
}
/**
* Set the author of the acknowledged
*
* @param string $author
*
* @return $this
*/
public function setAuthor($author)
{
$this->author = $author;
return $this;
}
/**
* Get the message of the acknowledged
*
* @return string
*/
public function getMessage()
{
return $this->message;
}
/**
* Set the message of the acknowledged
*
* @param string $message
*
* @return $this
*/
public function setMessage($message)
{
$this->message = $message;
$this->hash = null;
return $this;
}
/**
* Get the start date and time of the acknowledged
*
* @return int
*/
public function getStart()
{
return $this->start;
}
/**
* Set the start date and time of the acknowledged
*
* @param int $start
*
* @return $this
*/
public function setStart($start)
{
$this->start = $start;
return $this;
}
/**
* Get the end date and time of the acknowledged
*
* @return int
*/
public function getEnd()
{
return $this->end;
}
/**
* Set the end date and time of the acknowledged
*
* @param int $end
*
* @return $this
*/
public function setEnd($end)
{
$this->end = $end;
return $this;
}
/**
* Get the hash of the acknowledgement
*
* @return string
*/
public function getHash()
{
if ($this->hash === null) {
$this->hash = md5($this->message);
}
return $this->hash;
}
}

View File

@ -0,0 +1,137 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Web\Announcement;
use Icinga\Web\Cookie;
/**
* Handle acknowledged announcements via cookie
*/
class AnnouncementCookie extends Cookie
{
/**
* Array of hashes representing acknowledged announcements
*
* @var string[]
*/
protected $acknowledged = array();
/**
* ETag of the last known announcements.ini
*
* @var string
*/
protected $etag;
/**
* Timestamp of the next active acknowledgement, if any
*
* @var int|null
*/
protected $nextActive;
/**
* AnnouncementCookie constructor
*/
public function __construct()
{
parent::__construct('icingaweb2-announcements');
$this->setExpire(2147483648);
if (isset($_COOKIE['icingaweb2-announcements'])) {
$cookie = json_decode($_COOKIE['icingaweb2-announcements'], true);
if ($cookie !== null) {
if (isset($cookie['acknowledged'])) {
$this->setAcknowledged($cookie['acknowledged']);
}
if (isset($cookie['etag'])) {
$this->setEtag($cookie['etag']);
}
if (isset($cookie['next'])) {
$this->setNextActive($cookie['next']);
}
}
}
}
/**
* Get the hashes of the acknowledged announcements
*
* @return string[]
*/
public function getAcknowledged()
{
return $this->acknowledged;
}
/**
* Set the hashes of the acknowledged announcements
*
* @param string[] $acknowledged
*
* @return $this
*/
public function setAcknowledged(array $acknowledged)
{
$this->acknowledged = $acknowledged;
return $this;
}
/**
* Get the ETag
*
* @return string
*/
public function getEtag()
{
return $this->etag;
}
/**
* Set the ETag
*
* @param string $etag
*
* @return $this
*/
public function setEtag($etag)
{
$this->etag = $etag;
return $this;
}
/**
* Get the timestamp of the next active announcement
*
* @return int
*/
public function getNextActive()
{
return $this->nextActive;
}
/**
* Set the timestamp of the next active announcement
*
* @param int $nextActive
*
* @return $this
*/
public function setNextActive($nextActive)
{
$this->nextActive = $nextActive;
return $this;
}
/**
* {@inheritdoc}
*/
public function getValue()
{
return json_encode(array(
'acknowledged' => $this->getAcknowledged(),
'etag' => $this->getEtag(),
'next' => $this->getNextActive()
));
}
}

View File

@ -0,0 +1,159 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Web\Announcement;
use DateTime;
use Icinga\Data\ConfigObject;
use Icinga\Data\Filter\Filter;
use Icinga\Data\Filter\FilterAnd;
use Icinga\Data\SimpleQuery;
use Icinga\Repository\IniRepository;
use Icinga\Web\Announcement;
/**
* A collection of announcements stored in an INI file
*/
class AnnouncementIniRepository extends IniRepository
{
/**
* {@inheritdoc}
*/
protected $queryColumns = array('announcement' => array('id', 'author', 'message', 'hash', 'start', 'end'));
/**
* {@inheritdoc}
*/
protected $triggers = array('announcement');
/**
* {@inheritDoc}
*/
protected $configs = array('announcement' => array(
'name' => 'announcements',
'keyColumn' => 'id'
));
/**
* {@inheritDoc}
*/
protected $conversionRules = array('announcement' => array(
'start' => 'timestamp',
'end' => 'timestamp'
));
/**
* Create a DateTime from a timestamp
*
* @param string $timestamp
*
* @return DateTime|null
*/
protected function retrieveTimestamp($timestamp)
{
if ($timestamp !== null) {
$dateTime = new DateTime();
$dateTime->setTimestamp($timestamp);
return $dateTime;
}
return null;
}
/**
* Get a DateTime's timestamp
*
* @param DateTime $datetime
*
* @return int|null
*/
protected function persistTimestamp(DateTime $datetime)
{
return $datetime === null ? null : $datetime->getTimestamp();
}
/**
* Before-insert trigger (per row)
*
* @param ConfigObject $new The original data to insert
*
* @return ConfigObject The eventually modified data to insert
*/
protected function onInsertAnnouncement(ConfigObject $new)
{
if (! isset($new->id)) {
$new->id = uniqid();
}
if (! isset($new->hash)) {
$announcement = new Announcement($new->toArray());
$new->hash = $announcement->getHash();
}
return $new;
}
/**
* Before-update trigger (per row)
*
* @param ConfigObject $old The original data as currently stored
* @param ConfigObject $new The original data to update
*
* @return ConfigObject The eventually modified data to update
*/
protected function onUpdateAnnouncement(ConfigObject $old, ConfigObject $new)
{
if ($new->message !== $old->message) {
$announcement = new Announcement($new->toArray());
$new->hash = $announcement->getHash();
}
return $new;
}
/**
* Get the ETag of the announcements.ini file
*
* @return string
*/
public function getEtag()
{
$file = $this->getDataSource('announcement')->getConfigFile();
$mtime = filemtime($file);
$size = filesize($file);
return hash('crc32', $mtime . $size);
}
/**
* Get the query for all active announcements
*
* @return SimpleQuery
*/
public function findActive()
{
$now = new DateTime();
$query = $this
->select(array('hash', 'message'))
->setFilter(new FilterAnd(array(
Filter::expression('start', '<=', $now),
Filter::expression('end', '>=', $now)
)))
->order('start');
return $query;
}
/**
* Get the timestamp of the next active announcement
*
* @return int|null
*/
public function findNextActive()
{
$now = new DateTime();
$query = $this
->select(array('start'))
->setFilter(Filter::expression('start', '>', $now))
->order('start')
->limit(1);
$nextActive = $query->fetchRow();
return $nextActive !== false ? $nextActive->start->getTimestamp() : null;
}
}

View File

@ -0,0 +1,54 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Web\Widget;
use Icinga\Application\Icinga;
use Icinga\Data\Filter\Filter;
use Icinga\Forms\Announcement\AcknowledgeAnnouncementForm;
use Icinga\Web\Announcement\AnnouncementCookie;
use Icinga\Web\Announcement\AnnouncementIniRepository;
/**
* Render announcements
*/
class Announcements extends AbstractWidget
{
/**
* {@inheritdoc}
*/
public function render()
{
$repo = new AnnouncementIniRepository();
$etag = $repo->getEtag();
$cookie = new AnnouncementCookie();
if ($cookie->getEtag() !== $etag) {
$cookie->setEtag($etag);
$cookie->setNextActive($repo->findNextActive());
Icinga::app()->getResponse()->setCookie($cookie);
}
$acked = array();
foreach ($cookie->getAcknowledged() as $hash) {
$acked[] = Filter::expression('hash', '!=', $hash);
}
$acked = Filter::matchAll($acked);
$announcements = $repo->findActive();
$announcements->applyFilter($acked);
if ($announcements->hasResult()) {
$html = '<ul role="alert" id="announcements">';
foreach ($announcements as $announcement) {
$ackForm = new AcknowledgeAnnouncementForm();
$ackForm->populate(array('hash' => $announcement->hash));
$html .= '<li><div>'
. $this->view()->escape($announcement->message)
. '</div>'
. $ackForm
. '</li>';
}
$html .= '</ul>';
return $html;
}
// Force container update on XHR
return '<div style="display: none;"></div>';
}
}

View File

@ -64,8 +64,9 @@ input.search {
}
}
// TODO(el): .form-inline control-group { display: inline-block; }
form.inline {
// TODO(el): .form-controls-inline control-group { display: inline-block; }
form.inline,
.form-inline {
.control-group {
padding: 0;
}

View File

@ -1,5 +1,50 @@
/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
#announcements > ul {
background-color: @body-bg-color;
font-size: @font-size;
list-style: none;
margin: 0;
padding: 0;
> li:before {
color: @icinga-blue;
content: "\e811";
font-family: 'ifont';
left: 1em;
margin-top: -0.8em;
padding: 0.3em;
position: absolute;
text-align: center;
top: 50%;
}
> li {
border-bottom: 1px solid @gray-lighter;
padding: 1em 3em;
position: relative;
}
> li .message {
display: inline-block;
vertical-align: middle;
}
> li:last-child {
border-bottom: none;
}
.acknowledge-announcement-control {
background: none;
border: none;
display: block;
margin-top: -0.6em;
position: absolute;
right: 1em;
top: 50%;
}
}
table.historycolorgrid {
font-size: 1.5em;
}

View File

@ -445,6 +445,10 @@
return;
}
if (req.getResponseHeader('X-Icinga-Announcements') === 'refresh') {
_this.loadUrl(_this.url('/layout/announcements'), $('#announcements'));
}
// div helps getting an XML tree
var $resp = $('<div>' + req.responseText + '</div>');
var active = false;