system: adjust for overrides banner

Introduces the isBanner property, which explicitly defines the message
as a banner, which doesn't necessarily have a relation to
the persistent property. While here, update the UI to remove
cursor events when the message doesn't have a location set.
This commit is contained in:
Stephan de Wit 2025-01-14 17:25:39 +01:00 committed by Franco Fichtner
parent 1850661335
commit fd39bafe72
10 changed files with 267 additions and 166 deletions

1
plist
View File

@ -575,6 +575,7 @@
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/DiskSpaceStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/FirewallStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/LiveMediaStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/OpensshOverrideStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/SystemBootingStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/SystemStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/SystemStatusCode.php

View File

@ -75,8 +75,8 @@ class SystemController extends ApiControllerBase
foreach ($statuses as $subsystem => $status) {
$statuses[$subsystem]['status'] = $order[$status['statusCode']];
if (!empty($status['logLocation'])) {
if (!$acl->isPageAccessible($this->getUserName(), $status['logLocation'])) {
if (!empty($status['location'])) {
if (!$acl->isPageAccessible($this->getUserName(), $status['location'])) {
unset($statuses[$subsystem]);
continue;
}
@ -181,8 +181,8 @@ class SystemController extends ApiControllerBase
$subsystem = $this->request->getPost("subject");
$system = json_decode(trim($backend->configdRun('system status')), true);
if (array_key_exists($subsystem, $system)) {
if (!empty($system[$subsystem]['logLocation'])) {
$aclCheck = $system[$subsystem]['logLocation'];
if (!empty($system[$subsystem]['location'])) {
$aclCheck = $system[$subsystem]['location'];
if (
$acl->isPageAccessible($this->getUserName(), $aclCheck) ||
!$acl->hasPrivilege($this->getUserName(), 'user-config-readonly')

View File

@ -32,9 +32,10 @@ abstract class AbstractStatus
{
protected $internalPriority = 100;
protected $internalPersistent = false;
protected $internalIsBanner = false;
protected $internalTitle = null;
protected $internalMessage = null;
protected $internalLogLocation = null;
protected $internalLocation = null;
protected $internalStatus = SystemStatusCode::OK;
protected $internalTimestamp = null;
@ -48,6 +49,11 @@ abstract class AbstractStatus
return $this->internalPersistent;
}
public function isBanner()
{
return $this->internalIsBanner;
}
public function getTitle()
{
return $this->internalTitle;
@ -63,9 +69,9 @@ abstract class AbstractStatus
return $this->internalMessage ?? gettext('No problems were detected.');
}
public function getLogLocation()
public function getLocation()
{
return $this->internalLogLocation;
return $this->internalLocation;
}
public function getTimestamp()

View File

@ -41,7 +41,7 @@ class CrashReporterStatus extends AbstractStatus
$this->internalPriority = 10;
$this->internalTitle = gettext('Crash Reporter');
$this->internalLogLocation = '/crash_reporter.php';
$this->internalLocation = '/crash_reporter.php';
$src_errors = count($src_logs) > 0;
if ($src_errors) {

View File

@ -39,7 +39,7 @@ class FirewallStatus extends AbstractStatus
{
$this->internalPriority = 20;
$this->internalTitle = gettext('Firewall');
$this->internalLogLocation = '/ui/diagnostics/log/core/firewall';
$this->internalLocation = '/ui/diagnostics/log/core/firewall';
if (file_exists($this->rules_error)) {
$this->internalMessage = file_get_contents($this->rules_error); /* XXX */

View File

@ -38,6 +38,7 @@ class LiveMediaStatus extends AbstractStatus
{
$this->internalPriority = 2;
$this->internalPersistent = true;
$this->internalIsBanner = true;
$this->internalTitle = gettext('Live Media');
/*

View File

@ -0,0 +1,48 @@
<?php
/*
* Copyright (C) 2025 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\Status;
use OPNsense\System\AbstractStatus;
use OPNsense\System\SystemStatusCode;
class OpensshOverrideStatus extends AbstractStatus
{
public function __construct()
{
$this->internalPriority = 2;
$this->internalPersistent = true;
$this->internalTitle = gettext('OpenSSH config override');
$this->internalLocation = '/system_advanced_admin.php';
if (count(glob('/usr/local/etc/ssh/sshd_config.d/*.conf'))) {
$this->internalMessage = gettext('The OpenSSH GUI configuration may be overridden by currently provided files on the disk.');
$this->internalStatus = SystemStatusCode::NOTICE;
}
}
}

View File

@ -37,6 +37,7 @@ class SystemBootingStatus extends AbstractStatus
{
$this->internalPriority = 1;
$this->internalPersistent = true;
$this->internalIsBanner = true;
$this->internalTitle = gettext('System Booting');
/* XXX boot detection from final class product in config.inc */

View File

@ -75,9 +75,10 @@ class SystemStatus
'title' => $obj->getTitle(),
'statusCode' => $obj->getStatus(),
'message' => $obj->getMessage(),
'logLocation' => $obj->getLogLocation(),
'location' => $obj->getLocation(),
'timestamp' => $obj->getTimestamp(),
'persistent' => $obj->getPersistent(),
'isBanner' => $obj->isBanner(),
'priority' => $obj->getPriority(),
];
}

View File

@ -1,5 +1,5 @@
/**
* Copyright (C) 2022-2024 Deciso B.V.
* Copyright (C) 2022-2025 Deciso B.V.
*
* All rights reserved.
*
@ -25,181 +25,224 @@
* POSSIBILITY OF SUCH DAMAGE.
*/
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>
`);
class Status {
constructor() {
this.observers = [];
this.data = null;
}
let $message = $(
'<div>' +
'<div id="opn-status-list"></div>' +
'</div>'
);
for (let [shortname, subject] of Object.entries(status)) {
$message.find('a').last().addClass('__mb');
let formattedSubject = subject.title;
if (subject.age != undefined) {
formattedSubject += '&nbsp;<small>(' + subject.age + ')</small>';
}
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('#dismiss-' + shortname).on('click', function (e) {
e.preventDefault();
$.ajax('/api/core/system/dismissStatus', {
type: 'post',
data: {'subject': shortname},
dialogRef: dialog,
success: function() {
updateSystemStatus(this.dialogRef);
}
updateStatus() {
const fetch = new Promise((resolve, reject) => {
ajaxGet('/api/core/system/status', {}, function (data) {
resolve(data);
});
});
$ret = $message;
}
return $ret;
}
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 fetchSystemStatus() {
return new Promise((resolve, reject) => {
ajaxGet('/api/core/system/status', {}, function (data) {
resolve(data);
fetch.then((data) => {
this.notify(data);
});
});
}
attach(observer) {
this.observers.push(observer);
}
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
function parseStatus(data) {
let system = data.metadata.system;
class StatusIcon {
update(status) {
const icon = this._parseStatusIcon(status.metadata.system.status);
$('#system_status').removeClass().addClass(icon);
}
// handle initial page load status icon
parseStatusIcon(system);
$('#system_status').removeClass().addClass(system.icon);
_parseStatusIcon(statusCode) {
switch (statusCode) {
case "ERROR":
return 'fa fa-circle text-danger';
case "WARNING":
return 'fa fa-circle text-warning';
case "NOTICE":
return 'fa fa-circle text-info';
default:
return 'fa fa-circle text-muted';
}
}
let notifications = {};
let bannerMessages = {};
for (let [shortname, subject] of Object.entries(data.subsystems)) {
parseStatusIcon(subject);
asClass(statusCode) {
return this._parseStatusIcon(statusCode);
}
}
if (subject.status == "OK")
continue;
class StatusDialog {
constructor() {
this.clickHandlerRegistered = false;
this.dialogOpen = false;
this.currentStatus = null;
this.dialog = null;
}
if (subject.persistent) {
bannerMessages[shortname] = subject;
update(status) {
this.currentStatus = status;
if (!this.clickHandlerRegistered) {
this.clickHandlerRegistered = true;
const translations = status.metadata.translations;
$('#system_status').click(() => {
this.dialog = new BootstrapDialog({
title: translations.dialogTitle,
draggable: true,
buttons: [{
id: 'close',
label: translations.dialogCloseButton,
action: (dialogRef) => {
dialogRef.close();
this.dialogOpen = false;
}
}],
});
this._setDialogContent(this.currentStatus);
this.dialog.open();
this.dialogOpen = true;
});
} else {
notifications[shortname] = subject;
this._setDialogContent(this.currentStatus);
if (!this.dialogOpen) {
this.dialog.open();
this.dialogOpen = true;
}
}
}
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];
$('.page-content-main > .container-fluid > .row').prepend($(`
<div class="container-fluid">
<div id="notification-banner" class="alert alert-info ${banner.banner}"
style="padding: 10px; text-align: center;">
${banner.message}
</div>
_setDialogContent(status) {
this.dialog.setMessage((dialog) => {
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>'
);
$("#system_status").click(function() {
fetchSystemStatus().then((data) => {
let translations = data.metadata.translations;
let status = parseStatus(data);
for (let [shortname, subject] of Object.entries(status.subsystems)) {
if (subject.status == "OK")
continue;
dialog = new BootstrapDialog({
title: translations.dialogTitle,
buttons: [{
id: 'close',
label: translations.dialogCloseButton,
action: function(dialogRef) {
dialogRef.close();
}
}],
});
$message.find('a').last().addClass('__mb');
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});
})
let formattedSubject = subject.title;
if (subject.age != undefined) {
formattedSubject += '&nbsp;<small>(' + subject.age + ')</small>';
}
dialog.open();
let ref = subject.location != null ? `href="${subject.location}"` : '';
let hoverStyle = subject.location == null ? 'cursor: default; pointer-events: none;' : '';
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; ${hoverStyle}" ${ref}>
<h4>
<span class="${(new StatusIcon()).asClass(subject.status)}"></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('#dismiss-' + shortname).on('click', function (e) {
e.preventDefault();
$.ajax('/api/core/system/dismissStatus', {
type: 'post',
data: {'subject': shortname},
dialogRef: dialog,
itemRef: $listItem,
success: function() {
statusObj.updateStatus();
}
});
});
$ret = $message;
}
return $ret;
});
});
}
}
class StatusBanner {
constructor() {
this.bannerActive = false;
}
update(status) {
for (let [name, subject] of Object.entries(status.subsystems)) {
if (subject.status == "OK")
continue;
if (subject.isBanner && !this.bannerActive) {
if (!this.bannerActive) {
$('.page-content-main > .container-fluid > .row').prepend($(`
<div class="container-fluid">
<div id="notification-banner" class="alert alert-info ${this.parseStatusBanner(subject.status)}"
style="padding: 10px; text-align: center;">
${subject.message}
</div>
</div>
`));
this.bannerActive = true;
break;
} else {
$('#notification-banner').text(subject.message);
$('#notification-banner').removeClass().addClass(`alert alert-info ${this.parseStatusBanner(subject.status)}`);
}
}
}
}
parseStatusBanner(statusCode) {
switch (statusCode) {
case "ERROR":
return 'alert-danger';
case "WARNING":
return 'alert-warning';
default:
return 'alert-info';
}
}
}
const statusObj = new Status();
function updateSystemStatus() {
statusObj.attach(new StatusIcon());
statusObj.attach(new StatusDialog());
statusObj.attach(new StatusBanner());
statusObj.updateStatus();
}