dashboard: add firewall widget, change BaseTableWidget accordingly

The BaseTableWidget now contains some more rudimentary options to update
existing rows and sort on a specific column index.

The firewall widget counts events live as they happen and populates a
table in a larger view, or a doughnut chart in a smaller view as
data comes in.
This commit is contained in:
Stephan de Wit 2024-05-13 12:04:39 +02:00
parent 0a45505611
commit 2785cb641f
8 changed files with 443 additions and 93 deletions

1
plist
View File

@ -1955,6 +1955,7 @@
/usr/local/opnsense/www/js/widgets/BaseWidget.js
/usr/local/opnsense/www/js/widgets/Cpu.js
/usr/local/opnsense/www/js/widgets/Disk.js
/usr/local/opnsense/www/js/widgets/Firewall.js
/usr/local/opnsense/www/js/widgets/InterfaceStatistics.js
/usr/local/opnsense/www/js/widgets/Interfaces.js
/usr/local/opnsense/www/js/widgets/Memory.js

View File

@ -88,6 +88,21 @@ class DashboardController extends ApiControllerBase
'peer' => gettext('Peer'),
'pubkey' => gettext('Public Key'),
'handshake' => gettext('Latest handshake'),
],
'firewall' => [
'title' => gettext('Firewall'),
'action' => gettext('Action'),
'time' => gettext('Time'),
'interface' => gettext('Interface'),
'source' => gettext('Source'),
'destination' => gettext('Destination'),
'port' => gettext('Port'),
'matchedrule' => gettext('Matched rule'),
'click' => gettext('Click to track this rule in Live View'),
'label' => gettext('Label'),
'count' => gettext('Count'),
'livelog' => gettext('Live Log'),
'events' => gettext('Events'),
]
];
}

View File

@ -58,6 +58,19 @@ class FirewallController extends ApiControllerBase
}
}
public function streamLogAction()
{
return $this->configdStream(
'filter stream log',
[],
[
'Content-Type: text/event-stream',
'Cache-Control: no-cache'
],
60
);
}
/**
* retrieve firewall log filter choices
* @return array

View File

@ -172,6 +172,13 @@ td {
z-index: 20;
}
/* CPU widget */
.cpu-type {
margin-bottom: 10px;
margin-top: 10px;
}
/* ----- */
/* Custom flex table */
div {
box-sizing: border-box;
@ -241,31 +248,26 @@ div {
.column .flex-cell:not(:last-child) {
border-bottom: solid 1px rgba(217, 79, 0, 0.15);
}
/* ----- */
.cpu-type {
margin-bottom: 10px;
margin-top: 10px;
}
/* CSS grid responsive table */
.grid-table {
margin: 2em auto;
width: 95%;
}
.grid-header-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
}
.grid-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
background-color: #f7e2d6; /* fade-in color, transitions to transparent */
border-top: 1px solid #f7e2d6;
transition: 0.5s;
opacity: 0.4;
}
.grid-row:hover {
background: #F5F5F5 !important;
transition: 500ms;
}
.grid-header {
@ -278,5 +280,4 @@ div {
padding: 4px;
text-align: center;
}
/* ----- */

View File

@ -31,9 +31,7 @@ export default class BaseTableWidget extends BaseWidget {
constructor() {
super();
this.options = null;
this.data = [];
this.tables = {};
this.curSize = null;
this.sizeStates = {
0: {
@ -53,63 +51,43 @@ export default class BaseTableWidget extends BaseWidget {
}
}
this.widths = Object.keys(this.sizeStates).sort();
this.flextableId = Math.random().toString(36).substring(7);
this.$flextable = null;
this.$headerContainer = null;
}
_calculateColumnWidth() {
if (this.options !== null && this.data !== null) {
switch (this.options.headerPosition) {
case 'none':
return `calc(100% / ${this.data[0].length})`;
case 'left':
return `calc(100% / 2)`;
for (const [id, tableObj] of Object.entries(this.tables)) {
if (tableObj.options.headerPosition === 'left') {
return `calc(100% / 2)`;
}
if (tableObj.options.headerPosition === 'top') {
return `calc(100% / ${tableObj.data[0].length})`;
}
}
return '';
}
_constructTable() {
if (this.options === null) {
console.error('No table options set');
return null;
_rotate(id, newElement) {
let opts = this.tables[id].options;
let data = this.tables[id].data;
data.unshift(newElement);
if (data.length > opts.rotation) {
data.splice(opts.rotation);
}
if (this.options.headerPosition === 'top') {
this.$flextable = $(`<div class="grid-table" id="id_${this.flextableId}" role="table"></div>`)
this.$headerContainer = $(`<div id="header_${this.flextableId}" class="grid-header-container"></div>`);
for (const h of this.options.headers) {
this.$headerContainer.append($(`
<div class="grid-item grid-header">${h}</div>
`));
}
this.$flextable.append(this.$headerContainer);
} else {
this.$flextable = $(`<div class="flextable-container" id="id_${this.flextableId}" role="table"></div>`)
}
}
_rotate(arr, newElement) {
arr.unshift(newElement);
if (arr.length > this.options.rotation) {
arr.splice(this.options.rotation);
}
const divs = document.querySelectorAll(`#id_${this.flextableId} .grid-row`);
if (divs.length > this.options.rotation) {
for (let i = this.options.rotation; i < divs.length; i++) {
const divs = document.querySelectorAll(`#${id} .grid-row`);
if (divs.length > opts.rotation) {
for (let i = opts.rotation; i < divs.length; i++) {
$(divs[i]).remove();
}
}
}
setTableOptions(options = {}) {
createTable(id, options) {
/**
* Options:
*
* headerPosition: top, left or none.
* top: headers are on top of the table. Headers are defined in the options. Data layout:
* [
@ -126,27 +104,66 @@ export default class BaseTableWidget extends BaseWidget {
*
* none: no headers, same data layout as 'top', without headers set as an option.
*
* rotation: limit table entries to a certain amount, and rotate them. Only applicable for headerPosition: top.
* rotation: limit table entries to a certain amount and rotate them. Only applicable for headerPosition: top.
* headers: list of headers to display. Only applicable for headerPosition: top.
* sortIndex: index of the column to sort on. Only applicable for headerPosition: top.
* sortOrder: 'asc' or 'desc'. Only applicable for headerPosition: top.
*
*/
if (this.options === null) {
console.error('No table options set');
return null;
}
this.options = {
let mergedOpts = {
headerPosition: 'top',
rotation: false,
...options // merge and override defaults
sortIndex: null,
sortOrder: 'desc',
...options
}
let $table = null;
let $headerContainer = null;
if (mergedOpts.headerPosition === 'top') {
/* CSS grid implementation */
$table = $(`<div class="grid-table" id="${id}" role="table"></div>`);
$headerContainer = $(`<div id="header_${id}" class="grid-header-container"></div>`);
for (const h of mergedOpts.headers) {
$headerContainer.append($(`
<div class="grid-item grid-header">${h}</div>
`));
}
$table.append($headerContainer);
} else {
/* flextable implementation */
$table = $(`<div class="flextable-container" id="${id}" role="table"></div>`);
}
this.tables[id] = {
'table': $table,
'options': mergedOpts,
'headerContainer': $headerContainer,
'data': [],
};
return $table;
}
updateTable(data = []) {
let $table = $(`#id_${this.flextableId}`);
updateTable(id, data = [], rowIdentifier = null) {
let $table = $(`#${id}`);
let options = this.tables[id].options;
if (!this.options.rotation) {
if (!options.rotation) {
$table.children('.flextable-row').remove();
this.data = data;
this.tables[id].data = data;
}
for (const row of data) {
switch (this.options.headerPosition) {
switch (options.headerPosition) {
case "none":
let $row = $(`<div class="flextable-row"></div>`)
for (const item of row) {
@ -157,15 +174,45 @@ export default class BaseTableWidget extends BaseWidget {
$table.append($row);
break;
case "top":
let $gridRow = $(`<div class="grid-row" style="opacity: 0.4; background-color: #f7e2d6"></div>`);
for (const item of row) {
$gridRow.append($(`
<div class="grid-item">${item}</div>
`));
let $gridRow = $(`<div class="grid-row"></div>`);
let newElement = true;
if (rowIdentifier !== null) {
$gridRow = $(`#id_${rowIdentifier}`);
if ($gridRow.length === 0) {
$gridRow = $(`<div class="grid-row" id="id_${rowIdentifier}"></div>`);
} else {
newElement = false;
$gridRow.empty();
}
}
$(`#header_${this.flextableId}`).after($gridRow);
if (this.options.rotation) {
let i = 0;
for (const item of row) {
$gridRow.append($(`
<div class="grid-item ${(options.sortIndex !== null && options.sortIndex == i) ? 'sort' : ''}">${item}</div>
`));
i++;
}
if (newElement) {
$(`#header_${id}`).after($gridRow);
}
if (options.sortIndex !== null) {
let items = $table.children('.grid-row').toArray().sort(function(a, b) {
let vA = parseInt($(a).children('.sort').first().text());
let vB = parseInt($(b).children('.sort').first().text());
if (options.sortOrder === 'asc')
return (vA < vB) ? -1 : (vA > vB) ? 1 : 0;
else {
return (vA > vB) ? -1 : (vA < vB) ? 1 : 0;
}
});
$table.append(items);
}
if (options.rotation) {
$gridRow.animate({
from: 0,
to: 255,
@ -174,11 +221,12 @@ export default class BaseTableWidget extends BaseWidget {
duration: 50,
easing: 'linear',
step: function(now) {
$gridRow.css('background-color',`transparent`)
$gridRow.css('background-color','initial')
}
});
this._rotate(this.data, row);
this._rotate(id, row);
}
break;
case "left":
if (row.length !== 2) {
@ -213,15 +261,6 @@ export default class BaseTableWidget extends BaseWidget {
}
}
getMarkup() {
this._constructTable();
return $(this.$flextable);
}
async onMarkupRendered() {
}
onWidgetResize(elem, width, height) {
let lowIndex = 0;
for (let i = 0; i < this.widths.length; i++) {

View File

@ -0,0 +1,278 @@
// endpoint:/api/diagnostics/firewall/streamLog
// endpoint:/api/diagnostics/interface/getInterfaceNames
/**
* 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.
*/
import BaseTableWidget from "./BaseTableWidget.js";
export default class Firewall extends BaseTableWidget {
constructor() {
super();
this.eventSource = null;
this.ifMap = {};
this.counters = {};
this.chart = null;
}
getMarkup() {
let $container = $('<div></div>');
let $tableContainer = $(`<div id="fw-table-container"><b>${this.translations.livelog}</b></div>`);
let $top_table = this.createTable('fw-top-table', {
headerPosition: 'top',
rotation: 5,
headers: [
this.translations.action,
this.translations.time,
this.translations.interface,
this.translations.source,
this.translations.destination,
this.translations.port
],
});
let $rule_table = this.createTable('fw-rule-table', {
headerPosition: 'top',
rotation: 5,
headers: [
this.translations.label,
this.translations.count
],
sortIndex: 1,
sortOrder: 'desc'
});
$tableContainer.append($top_table);
$tableContainer.append(`<div style="margin-top: 2em"><b>${this.translations.events}</b><div>`);
$tableContainer.append($rule_table);
$container.append($tableContainer);
$container.append($(`
<div class="fw-chart-container">
<div class="canvas-container">
<canvas id="fw-chart"></canvas>
</div>
</div>
`));
return $container;
}
_onMessage(event) {
if (!event) {
this.eventSource.close();
}
let actIcons = {
'pass': '<i class="fa fa-play text-success"></i>',
'block': '<i class="fa fa-minus-circle text-danger"></i>',
'rdr': '<i class="fa fa-exchange text-info"></i>',
'nat': '<i class="fa fa-exchange text-info"></i>',
}
const data = JSON.parse(event.data);
// increase counters
if (!this.counters[data.rid]) {
this.counters[data.rid] = {
count: 1,
label: data.label ?? ''
}
} else {
this.counters[data.rid].count++;
}
let popContent = $(`
<p>
@${data.rulenr}
${data.label.length > 0 ? 'Label: ' + data.label : ''}
<br>
<sub>${this.translations.click}</sub>
</p>
`).prop('outerHTML');
let popover = $(`
<a target="_blank" href="/ui/diagnostics/firewall/log?rid=${data.rid}" type="button"
data-toggle="popover" data-trigger="hover" data-html="true" data-title="${this.translations.matchedrule}"
data-content="${popContent}">
${actIcons[data.action]}
</a>
`);
super.updateTable('fw-top-table', [
[
popover.prop('outerHTML'),
/* Format time based on client browser locale */
(new Intl.DateTimeFormat(undefined, {hour: 'numeric', minute: 'numeric'})).format(new Date(data.__timestamp__)),
this.ifMap[data.interface] ?? data.interface,
data.src,
data.dst,
data.dstport ?? ''
]
]);
$('[data-toggle="popover"]').popover('hide');
$('[data-toggle="popover"]').popover({
container: 'body'
}).on('show.bs.popover', function() {
$(this).data("bs.popover").tip().css("max-width", "100%")
});
super.updateTable('fw-rule-table', [
[
$('<div style="text-align: left;"></div>').text(this.counters[data.rid].label).prop('outerHTML'),
this.counters[data.rid].count
]
], data.rid);
this._updateChart(data.rid, this.counters[data.rid].label, this.counters[data.rid].count);
}
_updateChart(rid, label, count) {
let labels = this.chart.data.labels;
let data = this.chart.data.datasets[0].data;
let rids = this.chart.data.datasets[0].rids;
let idx = rids.findIndex(x => x === rid);
if (idx === -1) {
labels.push(label);
data.push(count);
rids.push(rid);
} else {
data[idx] = count;
}
this.chart.update();
}
async onMarkupRendered() {
let exit = false;
ajaxGet('/api/diagnostics/interface/getInterfaceNames', {}, (data, status) => {
if (status !== 'success') {
console.error('Failed to fetch interface descriptions');
exit = true;
return;
}
this.ifMap = data;
});
if (exit) {
return;
}
this.eventSource = new EventSource('/api/diagnostics/firewall/streamLog');
this.eventSource.onmessage = this._onMessage.bind(this);
let context = document.getElementById('fw-chart').getContext('2d');
let config = {
type: 'doughnut',
data: {
labels: [],
datasets: [
{
data: [],
rids: [],
}
]
},
options: {
cutout: '40%',
maintainAspectRatio: true,
responsive: true,
aspectRatio: 2,
layout: {
padding: 10
},
normalized: true,
parsing: false,
onClick: (event, elements, chart) => {
const i = elements[0].index;
const rid = chart.data.datasets[0].rids[i];
window.open(`/ui/diagnostics/firewall/log?rid=${rid}`);
},
onHover: (event, elements) => {
event.native.target.style.cursor = elements[0] ? 'pointer' : 'grab';
},
plugins: {
legend: {
display: true,
position: 'left',
onHover: (event, legendItem) => {
const activeElement = {
datasetIndex: 0,
index: legendItem.index
};
this.chart.setActiveElements([activeElement]);
this.chart.tooltip.setActiveElements([activeElement]);
},
labels: {
filter: (ds, data) => {
/* clamp amount of legend labels to a max of 10 (sorted) */
const sortable = [];
data.labels.forEach((l, i) => {
sortable.push([l, data.datasets[0].data[i]]);
});
sortable.sort((a, b) => (b[1] - a[1]));
const sorted = sortable.slice(0, 10).map(e => (e[0]));
return sorted.includes(ds.text)
},
}
},
tooltip: {
callbacks: {
labels: (tooltipItem) => {
let obj = this.counters[tooltipItem.label];
return `${obj.label} (${obj.count})`;
}
}
}
}
}
}
this.chart = new Chart(context, config);
}
onWidgetClose() {
if (this.eventSource !== null) {
this.eventSource.close();
}
}
onWidgetResize(elem, width, height) {
if (width < 650) {
$('#fw-chart').show();
$('#fw-table-container').hide();
} else {
$('#fw-chart').hide();
$('#fw-table-container').show();
}
}
}

View File

@ -43,12 +43,13 @@ export default class Interfaces extends BaseTableWidget {
}
getMarkup() {
let options = {
let $container = $('<div></div>');
let $if_table = this.createTable('if-table', {
headerPosition: 'none'
}
});
super.setTableOptions(options);
return super.getMarkup();
$container.append($if_table);
return $container;
}
async onMarkupRendered() {
@ -102,7 +103,7 @@ export default class Interfaces extends BaseTableWidget {
rows.push(row);
});
super.updateTable(rows);
super.updateTable('if-table', rows);
$('[data-toggle="tooltip"]').tooltip();
});

View File

@ -36,10 +36,12 @@ export default class SystemInformation extends BaseTableWidget {
}
getMarkup() {
super.setTableOptions({
headerPosition: 'left'
let $container = $('<div></div>');
let $sysinfotable = this.createTable('sysinfo-table', {
headerPosition: 'left',
});
return super.getMarkup();
$container.append($sysinfotable);
return $container;
}
async onWidgetTick() {
@ -69,7 +71,7 @@ export default class SystemInformation extends BaseTableWidget {
rows.push([[this.translations['uptime']], $('<span id="uptime">').prop('outerHTML')]);
rows.push([[this.translations['datetime']], $('<span id="datetime">').prop('outerHTML')]);
rows.push([[this.translations['config']], $('<span id="config">').prop('outerHTML')]);
super.updateTable(rows);
super.updateTable('sysinfo-table', rows);
});
}
}