system: refactor system status mechanism, introduce persistent notifications

Also introduces better sorting with a separate priority value as well
as a refactored frontend. Includes some fixes for missing translations
as well.

To test a banner such as "the system is booting":

flock -n -o /var/run/booting cat
This commit is contained in:
Stephan de Wit 2024-12-12 11:17:39 +01:00
parent 761c364743
commit 1fc5a6335e
12 changed files with 280 additions and 138 deletions

View File

@ -1,7 +1,7 @@
<?php
/**
* Copyright (C) 2019-2022 Deciso B.V.
* Copyright (C) 2019-2024 Deciso B.V.
*
* All rights reserved.
*
@ -34,6 +34,8 @@ use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\ACL;
use OPNsense\Core\Backend;
use OPNsense\Core\Config;
use OPNsense\System\SystemStatus;
use OPNsense\System\SystemStatusCode;
/**
* Class SystemController
@ -68,30 +70,46 @@ class SystemController extends ApiControllerBase
$backend = new Backend();
$statuses = json_decode(trim($backend->configdRun('system status')), true);
if ($statuses) {
$order = [-1 => 'Error', 0 => 'Warning', 1 => 'Notice', 2 => 'OK'];
$order = SystemStatusCode::toValueNameArray();
$acl = new ACL();
foreach ($statuses as $subsystem => $status) {
$statuses[$subsystem]['status'] = $order[$status['statusCode']];
if (!empty($status['logLocation'])) {
if (!$acl->isPageAccessible($this->getUserName(), $status['logLocation'])) {
unset($statuses[$subsystem]);
continue;
}
} else {
/* XXX exiting loop causing global endpoint failure */
return $response;
}
}
/* Sort on the highest error level after the ACL check */
/* Sort on the highest notification (non-persistent) error level after the ACL check */
$statusCodes = array_map(function ($v) {
return $v['statusCode'];
return $v['persistent'] ? SystemStatusCode::OK : $v['statusCode'];
}, array_values($statuses));
sort($statusCodes);
$statuses['System'] = [
'status' => $order[$statusCodes[0] ?? 2]
$response['metadata'] = [
/* 'system' represents the status of the top notification after sorting */
'system' => [
'status' => $order[$statusCodes[0] ?? 2],
'message' => gettext('No pending messages'),
'title' => gettext('System'),
],
'translations' => [
'dialogTitle' => gettext('System Status'),
'dialogCloseButton' => gettext('Close')
]
];
// sort on status code and priority where priority is the tie breaker
uasort($statuses, function ($a, $b) {
if ($a['statusCode'] === $b['statusCode']) {
return $a['priority'] <=> $b['priority'];
}
return $a['statusCode'] <=> $b['statusCode'];
});
foreach ($statuses as &$status) {
if (!empty($status['timestamp'])) {
$age = time() - $status['timestamp'];
@ -150,7 +168,8 @@ class SystemController extends ApiControllerBase
}
}
$response = $statuses;
$response['subsystems'] = $statuses;
unset($response['status']);
}
return $response;

View File

@ -1,7 +1,7 @@
<?php
/*
* Copyright (C) 2022 Deciso B.V.
* Copyright (C) 2022-2024 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
@ -30,25 +30,37 @@ namespace OPNsense\System;
abstract class AbstractStatus
{
const STATUS_ERROR = -1;
const STATUS_WARNING = 0;
const STATUS_NOTICE = 1;
const STATUS_OK = 2;
protected $internalPriority = 100;
protected $internalPersistent = false;
protected $internalTitle = null;
protected $internalMessage = null;
protected $internalLogLocation = null;
protected $internalStatus = SystemStatusCode::OK;
protected $internalTimestamp = null;
protected $internalMessage = 'No problems were detected.';
protected $internalLogLocation = '';
protected $internalStatus = self::STATUS_OK;
protected $internalTimestamp = '0';
protected $statusStrings = ['notice', 'warning', 'error'];
public function getPriority()
{
return $this->internalPriority;
}
public function getPersistent()
{
return $this->internalPersistent;
}
public function getTitle()
{
return $this->internalTitle;
}
public function getStatus()
{
return $this->internalStatus;
}
public function getMessage($verbose = false)
public function getMessage()
{
return $this->internalMessage;
return $this->internalMessage ?? gettext('No problems were detected.');
}
public function getLogLocation()

View File

@ -1,7 +1,7 @@
<?php
/*
* Copyright (C) 2022 Deciso B.V.
* Copyright (C) 2022-2024 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
@ -30,6 +30,7 @@ namespace OPNsense\System\Status;
use OPNsense\System\AbstractStatus;
use OPNsense\Core\Config;
use OPNsense\System\SystemStatusCode;
class CrashReporterStatus extends AbstractStatus
{
@ -38,6 +39,8 @@ class CrashReporterStatus extends AbstractStatus
$src_logs = array_merge(glob('/var/crash/textdump*'), glob('/var/crash/vmcore*'));
$php_log = '/tmp/PHP_errors.log';
$this->internalPriority = 10;
$this->internalTitle = gettext('Crash Reporter');
$this->internalLogLocation = '/crash_reporter.php';
$src_errors = count($src_logs) > 0;
@ -61,10 +64,10 @@ class CrashReporterStatus extends AbstractStatus
if ($php_errors || $src_errors) {
$this->internalMessage = gettext('An issue was detected and can be reviewed using the firmware crash reporter.');
if ($php_errors) {
$this->internalStatus = Config::getInstance()->object()->system->deployment != 'development' ? static::STATUS_ERROR : static::STATUS_NOTICE;
$this->internalStatus = Config::getInstance()->object()->system->deployment != 'development' ? SystemStatusCode::ERROR : SystemStatusCode::NOTICE;
}
if ($src_errors && $this->internalStatus != static::STATUS_ERROR) {
$this->internalStatus = static::STATUS_WARNING;
if ($src_errors && $this->internalStatus != SystemStatusCode::ERROR) {
$this->internalStatus = SystemStatusCode::WARNING;
}
}
}

View File

@ -1,7 +1,7 @@
<?php
/*
* Copyright (C) 2022 Deciso B.V.
* Copyright (C) 2022-2024 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
@ -29,6 +29,7 @@
namespace OPNsense\System\Status;
use OPNsense\System\AbstractStatus;
use OPNsense\System\SystemStatusCode;
class FirewallStatus extends AbstractStatus
{
@ -36,11 +37,13 @@ class FirewallStatus extends AbstractStatus
public function __construct()
{
$this->internalPriority = 20;
$this->internalTitle = gettext('Firewall');
$this->internalLogLocation = '/ui/diagnostics/log/core/firewall';
if (file_exists($this->rules_error)) {
$this->internalMessage = file_get_contents($this->rules_error);
$this->internalStatus = static::STATUS_ERROR;
$this->internalMessage = file_get_contents($this->rules_error); /* XXX */
$this->internalStatus = SystemStatusCode::ERROR;
$info = stat($this->rules_error);
if (!empty($info['mtime'])) {
$this->internalTimestamp = $info['mtime'];

View File

@ -29,14 +29,16 @@
namespace OPNsense\System\Status;
use OPNsense\System\AbstractStatus;
use OPNsense\System\SystemStatusCode;
use OPNsense\Core\Config;
class LiveMediaStatus extends AbstractStatus
{
public function __construct()
{
/* XXX historically tied to the dashboard but only given because controller will not allow an omission */
$this->internalLogLocation = '/ui/core/dashboard';
$this->internalPriority = 2;
$this->internalPersistent = true;
$this->internalTitle = gettext('Live Media');
/*
* Despite unionfs underneath, / is still not writeable,
@ -54,7 +56,7 @@ class LiveMediaStatus extends AbstractStatus
return;
}
$this->internalStatus = static::STATUS_WARNING;
$this->internalStatus = SystemStatusCode::NOTICE;
$this->internalMessage = gettext('You are currently running in live media mode. A reboot will reset the configuration.');
if (empty(Config::getInstance()->object()->system->ssh->noauto)) {
exec('/bin/pgrep -anx sshd', $output, $retval); /* XXX portability shortcut */

View File

@ -29,20 +29,22 @@
namespace OPNsense\System\Status;
use OPNsense\System\AbstractStatus;
use OPNsense\System\SystemStatusCode;
class SystemBootingStatus extends AbstractStatus
{
public function __construct()
{
/* XXX historically tied to the dashboard but only given because controller will not allow an omission */
$this->internalLogLocation = '/ui/core/dashboard';
$this->internalPriority = 1;
$this->internalPersistent = true;
$this->internalTitle = gettext('System Booting');
/* XXX boot detection from final class product in config.inc */
$fp = fopen('/var/run/booting', 'a+e');
if ($fp) {
if (!flock($fp, LOCK_SH | LOCK_NB)) {
$this->internalMessage = gettext('The system is currently booting. Not all services have been started yet.');
$this->internalStatus = static::STATUS_WARNING;
$this->internalStatus = SystemStatusCode::WARNING;
}
fclose($fp);
}

View File

@ -1,7 +1,7 @@
<?php
/*
* Copyright (C) 2022 Deciso B.V.
* Copyright (C) 2022-2024 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
@ -63,7 +63,7 @@ class SystemStatus
foreach ($statuses as $statusClass) {
$obj = new $statusClass();
$reflect = new \ReflectionClass($obj);
$shortName = str_replace('Status', '', $reflect->getShortName());
$shortName = strtolower(str_replace('Status', '', $reflect->getShortName()));
$this->objectMap[$shortName] = $obj;
if ($shortName == 'System') {
@ -72,10 +72,13 @@ class SystemStatus
}
$result[$shortName] = [
'title' => $obj->getTitle(),
'statusCode' => $obj->getStatus(),
'message' => $obj->getMessage(),
'logLocation' => $obj->getLogLocation(),
'timestamp' => $obj->getTimestamp(),
'persistent' => $obj->getPersistent(),
'priority' => $obj->getPriority(),
];
}

View File

@ -0,0 +1,45 @@
<?php
/*
* Copyright (C) 2024 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\System;
enum SystemStatusCode:int
{
case ERROR = -1;
case WARNING = 0;
case NOTICE = 1;
case OK = 2;
public static function toValueNameArray(): array {
$result = [];
foreach (self::cases() as $case) {
$result[$case->value] = $case->name;
}
return $result;
}
}

View File

@ -96,22 +96,9 @@
initFormAdvancedUI();
addMultiSelectClearUI();
// Create status dialog instance
let dialog = new BootstrapDialog({
title: '{{ lang._('System Status')}}',
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialogRef) {
dialogRef.close();
}
}],
});
// artificial delay for UX reasons
setTimeout(function () {
updateSystemStatus().then((data) => {
let status = parseStatus(data);
registerStatusDelegate(dialog, status);
});
updateSystemStatus();
}, 500);
// Register collapsible table headers
@ -279,6 +266,12 @@
</ul>
</div>
</header>
<!-- notification banner -->
<div class="container-fluid">
<div id="notification-banner" class="alert alert-info" style="display: none; margin: 10px 0 10px 0; padding: 10px; text-align: center"></div>
</div>
<!-- page content -->
<section class="page-content-main">
<div class="container-fluid">

View File

@ -1,5 +1,5 @@
/**
* Copyright (C) 2022 Deciso B.V.
* Copyright (C) 2022-2024 Deciso B.V.
*
* All rights reserved.
*
@ -25,53 +25,63 @@
* POSSIBILITY OF SUCH DAMAGE.
*/
function updateStatusDialog(dialog, status, subjectRef = null) {
let $ret = $('<div><div><a data-dismiss="modal" class="btn btn-default" style="width:100%;text-align:left;" href="#"><h4><span class="fa fa-circle text-muted"></span>&nbsp;System</h4><p>No pending messages.</p></a></div></div>');
function updateStatusDialog(dialog, status) {
let $ret = $(`
<div>
<a data-dismiss="modal" class="btn btn-default" style="width:100%; text-align: left;" href="#">
<h4>
<span class="fa fa-circle text-muted">
</span>
&nbsp;
System
</h4>
<p>No pending messages.</p>
</a>
</div>
`);
let $message = $(
'<div>' +
'<div id="opn-status-list"></div>' +
'</div>'
);
for (let subject in status.data) {
if (subject === 'System') {
continue;
}
let statusObject = status.data[subject];
if (status.data[subject].status == "OK") {
continue;
}
for (let [shortname, subject] of Object.entries(status)) {
$message.find('a').last().addClass('__mb');
let formattedSubject = subject.replace(/([A-Z])/g, ' $1').trim();
if (status.data[subject].age != undefined) {
formattedSubject += '&nbsp;<small>(' + status.data[subject].age + ')</small>';
let formattedSubject = subject.title;
if (subject.age != undefined) {
formattedSubject += '&nbsp;<small>(' + subject.age + ')</small>';
}
let listItem = '<a class="btn btn-default" style="width:100%;text-align:left;" href="' + statusObject.logLocation + '">' +
'<h4><span class="' + statusObject.icon + '"></span>&nbsp;' + formattedSubject +
'<button id="dismiss-'+ subject + '" class="close"><span aria-hidden="true">&times;</span></button></h4></div>' +
'<p style="white-space: pre-wrap;">' + statusObject.message + '</p></a>';
let referral = statusObject.logLocation;
let ref = subject.logLocation != null ? `href="${subject.logLocation}"` : '';
let $closeBtn = `
<button id="dismiss-${shortname}" class="close">
<span aria-hidden="true">&times;</span>
</button>
`;
let $listItem = $(`
<a class="btn btn-default" style="width:100%; text-align: left;" ${ref}>
<h4>
<span class="${subject.icon}"></span>
&nbsp;
${formattedSubject}
${subject.persistent ? '' : $closeBtn}
</h4>
<p style="white-space: pre-wrap;">${subject.message}</p>
</a>
`)
$message.find('#opn-status-list').append(listItem);
$message.find('#opn-status-list').append($listItem);
$message.find('#dismiss-' + subject).on('click', function (e) {
$message.find('#dismiss-' + shortname).on('click', function (e) {
e.preventDefault();
$.ajax('/api/core/system/dismissStatus', {
type: 'post',
data: {'subject': subject},
data: {'subject': shortname},
dialogRef: dialog,
subjectRef: subject,
success: function() {
updateSystemStatus().then((data) => {
let newStatus = parseStatus(data);
let $newMessage = updateStatusDialog(this.dialogRef, newStatus, this.subjectRef);
this.dialogRef.setMessage($newMessage);
$('#system_status').attr("class", newStatus.data['System'].icon);
registerStatusDelegate(this.dialogRef, newStatus);
});
updateSystemStatus(this.dialogRef);
}
});
});
@ -81,49 +91,107 @@ function updateStatusDialog(dialog, status, subjectRef = null) {
return $ret;
}
function parseStatus(data) {
let status = {};
let severity = BootstrapDialog.TYPE_PRIMARY;
$.each(data, function(subject, statusObject) {
switch (statusObject.status) {
case "Error":
statusObject.icon = 'fa fa-circle text-danger'
if (subject != 'System') break;
severity = BootstrapDialog.TYPE_DANGER;
break;
case "Warning":
statusObject.icon = 'fa fa-circle text-warning';
if (subject != 'System') break;
severity = BootstrapDialog.TYPE_WARNING;
break;
case "Notice":
statusObject.icon = 'fa fa-circle text-info';
if (subject != 'System') break;
severity = BootstrapDialog.TYPE_INFO;
break;
default:
statusObject.icon = 'fa fa-circle text-muted';
if (subject != 'System') break;
break;
}
$('#system_status').removeClass().addClass(statusObject.icon);
});
status.severity = severity;
status.data = data;
return status;
function parseStatusIcon(subject) {
switch (subject.status) {
case "ERROR":
subject.icon = 'fa fa-circle text-danger';
subject.banner = 'alert-danger';
subject.severity = BootstrapDialog.TYPE_DANGER;
break;
case "WARNING":
subject.icon = 'fa fa-circle text-warning';
subject.banner ='alert-warning';
subject.severity = BootstrapDialog.TYPE_WARNING;
break;
case "NOTICE":
subject.icon = 'fa fa-circle text-info';
subject.banner = 'alert-info';
subject.severity = BootstrapDialog.TYPE_INFO;
break;
default:
subject.icon = 'fa fa-circle text-muted';
subject.banner = 'alert-info';
subject.severity = BootstrapDialog.TYPE_PRIMARY;
break;
}
}
function registerStatusDelegate(dialog, status) {
$("#system_status").click(function() {
dialog.setMessage(function(dialogRef) {
let $message = updateStatusDialog(dialogRef, status);
return $message;
function fetchSystemStatus() {
return new Promise((resolve, reject) => {
ajaxGet('/api/core/system/status', {}, function (data) {
resolve(data);
});
dialog.open();
});
}
function updateSystemStatus() {
return $.ajax('/api/core/system/status', { type: 'get', dataType: 'json' });
function parseStatus(data) {
let system = data.metadata.system;
// handle initial page load status icon
parseStatusIcon(system);
$('#system_status').removeClass().addClass(system.icon);
let notifications = {};
let bannerMessages = {};
for (let [shortname, subject] of Object.entries(data.subsystems)) {
parseStatusIcon(subject);
if (subject.status == "OK")
continue;
if (subject.persistent) {
bannerMessages[shortname] = subject;
} else {
notifications[shortname] = subject;
}
}
return {
'banners': bannerMessages,
'notifications': notifications
};
}
function updateSystemStatus(dialog = null) {
fetchSystemStatus().then((data) => {
let status = parseStatus(data); // will also update status icon
if (dialog != null) {
dialog.setMessage(function(dialogRef) {
return updateStatusDialog(dialogRef, status.notifications);
})
}
if (!$.isEmptyObject(status.banners)) {
let banner = Object.values(status.banners)[0];
$('#notification-banner').addClass(banner.banner).show().html(banner.message);
}
});
$("#system_status").click(function() {
fetchSystemStatus().then((data) => {
let translations = data.metadata.translations;
let status = parseStatus(data);
dialog = new BootstrapDialog({
title: translations.dialogTitle,
buttons: [{
id: 'close',
label: translations.dialogCloseButton,
action: function(dialogRef) {
dialogRef.close();
}
}],
});
dialog.setMessage(function(dialogRef) {
// intentionally do banners first, as these should always show on top
// in both cases normal backend sorting applies
return updateStatusDialog(dialogRef, {...status.banners, ...status.notifications});
})
dialog.open();
});
});
}

View File

@ -198,3 +198,8 @@ $aclObj = new \OPNsense\Core\ACL();
</ul>
</div>
</header>
<!-- notification banner -->
<div class="container-fluid">
<div id="notification-banner" class="alert alert-info" style="display: none; margin: 10px 0 10px 0; padding: 10px; text-align: center"></div>
</div>

View File

@ -183,22 +183,9 @@ $pagetitle .= html_safe(sprintf(' | %s.%s', $config['system']['hostname'], $conf
$('html,aside').scrollTop(($(this).offset().top - navbar_center));
});
// Create status dialog instance
let dialog = new BootstrapDialog({
title: "<?= html_safe(gettext('System Status')) ?>",
buttons: [{
label: "<?= html_safe(gettext('Close')) ?>",
action: function(dialogRef) {
dialogRef.close();
}
}],
});
// artifical delay for UX reasons
setTimeout(function () {
updateSystemStatus().then((data) => {
let status = parseStatus(data);
registerStatusDelegate(dialog, status);
});
updateSystemStatus();
}, 500);
// hook in live menu search