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 ceaeba3c7..7f8daba3b 100644
--- a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php
@@ -55,7 +55,17 @@ class DashboardController extends ApiControllerBase
'datetime' => gettext('Current date/time'),
'uptime' => gettext('Uptime'),
'config' => gettext('Last configuration change')
- ]
+ ],
+ 'interfacestatistics' => [
+ 'title' => gettext('Interface Statistics'),
+ 'bytesin' => gettext('Bytes In'),
+ 'bytesout' => gettext('Bytes Out'),
+ 'packetsin' => gettext('Packets In'),
+ 'packetsout' => gettext('Packets Out'),
+ 'errorsin' => gettext('Errors In'),
+ 'errorsout' => gettext('Errors Out'),
+ 'collisions' => gettext('Collisions'),
+ ],
];
}
diff --git a/src/opnsense/www/js/widgets/InterfaceStatistics.js b/src/opnsense/www/js/widgets/InterfaceStatistics.js
new file mode 100644
index 000000000..7ef0f737d
--- /dev/null
+++ b/src/opnsense/www/js/widgets/InterfaceStatistics.js
@@ -0,0 +1,177 @@
+// endpoint:/api/diagnostics/traffic/interface
+
+/**
+ * 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 BaseWidget from "./BaseWidget.js";
+
+export default class InterfaceStatistics extends BaseWidget {
+ constructor() {
+ super();
+
+ this.chart = null;
+ this.labels = [];
+ this.rawData = {};
+ this.dataset = {};
+ }
+
+ getMarkup() {
+ return $(`
+
+ `);
+ }
+
+ _setAlpha(color, opacity) {
+ const op = Math.round(Math.min(Math.max(opacity || 1, 0), 1) * 255);
+ return color + op.toString(16).toUpperCase();
+ }
+
+ _getIndexedData(data) {
+ let indexedData = Array(this.labels.length).fill(null);
+ let indexedColors = Array(this.labels.length).fill(null);
+ for (const item in data) {
+ let obj = data[item];
+ let idx = this.labels.indexOf(obj.name);
+ indexedData[idx] = obj.data;
+ indexedColors[idx] = obj.color;
+ }
+ return {
+ data: indexedData,
+ colors: indexedColors
+ };
+ }
+
+ async onWidgetTick() {
+ ajaxGet('/api/diagnostics/traffic/interface', {}, (data, status) => {
+ let i = 0;
+ let colors = Chart.colorschemes.tableau.Classic10;
+ for (const intf in data.interfaces) {
+ const obj = data.interfaces[intf];
+ this.labels.indexOf(obj.name) === -1 && this.labels.push(obj.name);
+ obj.data = parseInt(obj["packets received"]) + parseInt(obj["packets transmitted"]);
+ obj.color = colors[i % colors.length]
+ this.rawData[obj.name] = obj;
+ i++;
+ }
+
+ let formattedData = this._getIndexedData(data.interfaces);
+ this.dataset = {
+ label: 'statistics',
+ data: formattedData.data,
+ backgroundColor: formattedData.colors,
+ hoverBackgroundColor: formattedData.colors.map((color) => this._setAlpha(color, 0.5)),
+ fill: true,
+ borderWidth: 2,
+ hoverOffset: 10,
+ }
+
+ if (this.chart.config.data.datasets.length > 0) {
+ this.chart.config.data.datasets[0].data = this.dataset.data;
+ } else {
+ this.chart.config.data.labels = this.labels;
+ this.chart.config.data.datasets.push(this.dataset);
+ }
+
+ this.chart.update();
+ });
+ }
+
+ async onMarkupRendered() {
+ let context = $(`#intf-stats`)[0].getContext('2d');
+
+ let config = {
+ type: 'doughnut',
+ data: {
+ labels: [],
+ datasets: []
+ },
+ options: {
+ cutout: '40%',
+ maintainAspectRatio: true,
+ responsive: true,
+ aspectRatio: 2,
+ layout: {
+ padding: 10
+ },
+ parsing: false,
+ plugins: {
+ legend: {
+ display: false,
+ position: 'left',
+ title: 'Traffic',
+ onHover: (event, legendItem) => {
+ const activeElement = {
+ datasetIndex: 0,
+ index: legendItem.index
+ };
+ this.chart.setActiveElements([activeElement]);
+ this.chart.tooltip.setActiveElements([activeElement]);
+ this.chart.update();
+ }
+ },
+ tooltip: {
+ callbacks: {
+ label: (tooltipItem) => {
+ const idx = this.labels.indexOf(tooltipItem.label);
+ let obj = this.rawData[tooltipItem.label];
+ let result = [
+ `${tooltipItem.label}`,
+ `${this.translations.bytesin}: ${obj["bytes received"]}`,
+ `${this.translations.bytesout}: ${obj["bytes transmitted"]}`,
+ `${this.translations.packetsin}: ${obj["packets received"]}`,
+ `${this.translations.packetsout}: ${obj["packets transmitted"]}`,
+ `${this.translations.errorsin}: ${obj["input errors"]}`,
+ `${this.translations.errorsout}: ${obj["output errors"]}`,
+ `${this.translations.collisions}: ${obj["collisions"]}`,
+ ];
+ return result;
+ }
+ }
+ },
+ }
+ }
+ }
+
+ this.chart = new Chart(context, config);
+ }
+
+ onWidgetResize(elem, width, height) {
+ if (this.chart !== null) {
+ if (width > 450) {
+ this.chart.options.plugins.legend.display = true;
+ } else {
+ this.chart.options.plugins.legend.display = false;
+ }
+ }
+ return true;
+ }
+
+}