diff --git a/plist b/plist index 3647dd129..84327d51c 100644 --- a/plist +++ b/plist @@ -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 diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php index 68c1f06a3..7caabdcd6 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php @@ -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'), ] ]; } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Diagnostics/Api/FirewallController.php b/src/opnsense/mvc/app/controllers/OPNsense/Diagnostics/Api/FirewallController.php index ab9eb2ccb..e4e390d20 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Diagnostics/Api/FirewallController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Diagnostics/Api/FirewallController.php @@ -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 diff --git a/src/opnsense/www/css/dashboard.css b/src/opnsense/www/css/dashboard.css index 88242b37b..4b3cdeab4 100644 --- a/src/opnsense/www/css/dashboard.css +++ b/src/opnsense/www/css/dashboard.css @@ -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; } - /* ----- */ diff --git a/src/opnsense/www/js/widgets/BaseTableWidget.js b/src/opnsense/www/js/widgets/BaseTableWidget.js index dfb404345..b2ef30e56 100644 --- a/src/opnsense/www/js/widgets/BaseTableWidget.js +++ b/src/opnsense/www/js/widgets/BaseTableWidget.js @@ -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 = $(`
`) - this.$headerContainer = $(`
`); - - for (const h of this.options.headers) { - this.$headerContainer.append($(` -
${h}
- `)); - } - - this.$flextable.append(this.$headerContainer); - } else { - this.$flextable = $(`
`) - } - } - - _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 = $(`
`); + $headerContainer = $(`
`); + + for (const h of mergedOpts.headers) { + $headerContainer.append($(` +
${h}
+ `)); + } + + $table.append($headerContainer); + } else { + /* flextable implementation */ + $table = $(`
`); + } + + 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 = $(`
`) for (const item of row) { @@ -157,15 +174,45 @@ export default class BaseTableWidget extends BaseWidget { $table.append($row); break; case "top": - let $gridRow = $(`
`); - for (const item of row) { - $gridRow.append($(` -
${item}
- `)); + let $gridRow = $(`
`); + let newElement = true; + if (rowIdentifier !== null) { + $gridRow = $(`#id_${rowIdentifier}`); + if ($gridRow.length === 0) { + $gridRow = $(`
`); + } else { + newElement = false; + $gridRow.empty(); + } } - $(`#header_${this.flextableId}`).after($gridRow); - if (this.options.rotation) { + let i = 0; + for (const item of row) { + $gridRow.append($(` +
${item}
+ `)); + 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++) { diff --git a/src/opnsense/www/js/widgets/Firewall.js b/src/opnsense/www/js/widgets/Firewall.js new file mode 100644 index 000000000..1d230e73d --- /dev/null +++ b/src/opnsense/www/js/widgets/Firewall.js @@ -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 = $('
'); + let $tableContainer = $(`
${this.translations.livelog}
`); + 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(`
${this.translations.events}
`); + $tableContainer.append($rule_table); + + $container.append($tableContainer); + + $container.append($(` +
+
+ +
+
+ `)); + + return $container; + } + + _onMessage(event) { + if (!event) { + this.eventSource.close(); + } + + let actIcons = { + 'pass': '', + 'block': '', + 'rdr': '', + 'nat': '', + } + + 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 = $(` +

+ @${data.rulenr} + ${data.label.length > 0 ? 'Label: ' + data.label : ''} +
+ ${this.translations.click} +

+ `).prop('outerHTML'); + let popover = $(` + + ${actIcons[data.action]} + + `); + + 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', [ + [ + $('
').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(); + } + } +} \ No newline at end of file diff --git a/src/opnsense/www/js/widgets/Interfaces.js b/src/opnsense/www/js/widgets/Interfaces.js index c30894b43..08995deef 100644 --- a/src/opnsense/www/js/widgets/Interfaces.js +++ b/src/opnsense/www/js/widgets/Interfaces.js @@ -43,12 +43,13 @@ export default class Interfaces extends BaseTableWidget { } getMarkup() { - let options = { + let $container = $('
'); + 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(); }); diff --git a/src/opnsense/www/js/widgets/SystemInformation.js b/src/opnsense/www/js/widgets/SystemInformation.js index 92b8b8a21..f528c673c 100644 --- a/src/opnsense/www/js/widgets/SystemInformation.js +++ b/src/opnsense/www/js/widgets/SystemInformation.js @@ -36,10 +36,12 @@ export default class SystemInformation extends BaseTableWidget { } getMarkup() { - super.setTableOptions({ - headerPosition: 'left' + let $container = $('
'); + 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']], $('').prop('outerHTML')]); rows.push([[this.translations['datetime']], $('').prop('outerHTML')]); rows.push([[this.translations['config']], $('').prop('outerHTML')]); - super.updateTable(rows); + super.updateTable('sysinfo-table', rows); }); } }