dashboard: traffic widget

This commit is contained in:
Stephan de Wit 2024-04-11 10:04:20 +02:00
parent 354e964d70
commit 5a21f67030
10 changed files with 365 additions and 27 deletions

2
plist
View File

@ -1929,8 +1929,10 @@
/usr/local/opnsense/www/js/widgets/BaseTableWidget.js
/usr/local/opnsense/www/js/widgets/BaseWidget.js
/usr/local/opnsense/www/js/widgets/Cpu.js
/usr/local/opnsense/www/js/widgets/InterfaceStatistics.js
/usr/local/opnsense/www/js/widgets/Interfaces.js
/usr/local/opnsense/www/js/widgets/SystemInformation.js
/usr/local/opnsense/www/js/widgets/Traffic.js
/usr/local/opnsense/www/themes/opnsense/LICENSE
/usr/local/opnsense/www/themes/opnsense/assets/fonts/SourceSansPro-Bold/SourceSansPro-Bold.eot
/usr/local/opnsense/www/themes/opnsense/assets/fonts/SourceSansPro-Bold/SourceSansPro-Bold.otf

View File

@ -66,6 +66,11 @@ class DashboardController extends ApiControllerBase
'errorsout' => gettext('Errors Out'),
'collisions' => gettext('Collisions'),
],
'traffic' => [
'title' => gettext('Traffic Graph'),
'trafficin' => gettext('Traffic In'),
'trafficout' => gettext('Traffic Out'),
],
];
}

View File

@ -49,6 +49,19 @@ class TrafficController extends ApiControllerBase
return json_decode($response, true);
}
public function streamAction($poll_interval = 1)
{
return $this->configdStream(
'interface stream traffic',
[$poll_interval],
[
'Content-Type: text/event-stream',
'Cache-Control: no-cache',
],
$poll_interval + 1
);
}
/**
* retrieve interface top traffic hosts
* @param $interfaces string comma separated list of interfaces

View File

@ -31,15 +31,14 @@
<link href="{{ cache_safe('/ui/css/gridstack-extra.min.css') }}" rel="stylesheet">
<!-- gridstack core -->
<script src="{{ cache_safe('/ui/js/gridstack-all.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/moment-with-locales.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/opnsense_widget_manager.js') }}"></script>
<link rel="stylesheet" type="text/css" href="{{ cache_safe(theme_file_or_default('/css/dashboard.css', theme_name)) }}" rel="stylesheet" />
<script src="{{ cache_safe('/ui/js/moment-with-locales.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chart.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chartjs-plugin-streaming.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chartjs-plugin-colorschemes.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chartjs-adapter-moment.js') }}"></script>
<script src="{{ cache_safe('/ui/js/smoothie.js') }}"></script>
<link rel="stylesheet" type="text/css" href="{{ cache_safe(theme_file_or_default('/css/dashboard.css', theme_name)) }}" rel="stylesheet" />
<script>
$( document ).ready(function() {

View File

@ -29,16 +29,73 @@
require_once("interfaces.inc");
require_once("config.inc");
$result = array("interfaces" => array());
$interfaces = legacy_interface_stats();
$temp = gettimeofday();
$result['time'] = (double)$temp["sec"] + (double)$temp["usec"] / 1000000.0;
// collect user friendly interface names
foreach (legacy_config_get_interfaces(array("virtual" => false)) as $interfaceKey => $itf) {
if (array_key_exists($itf['if'], $interfaces)) {
$result['interfaces'][$interfaceKey] = $interfaces[$itf['if']];
$result['interfaces'][$interfaceKey]['name'] = !empty($itf['descr']) ? $itf['descr'] : $interfaceKey;
function map_ifs($ifs, $data) {
$result = ["interfaces" => []];
$temp = gettimeofday();
$result['time'] = (double)$temp["sec"] + (double)$temp["usec"] / 1000000.0;
foreach ($ifs as $interfaceKey => $itf) {
if (array_key_exists($itf['if'], $data)) {
$result['interfaces'][$interfaceKey] = [
"inbytes" => $data[$itf['if']]['bytes received'],
"outbytes" => $data[$itf['if']]['bytes transmitted'],
"inpkts" => $data[$itf['if']]['packets received'],
"outpkts" => $data[$itf['if']]['packets transmitted'],
"inerrs" => $data[$itf['if']]['input errors'],
"outerrs" => $data[$itf['if']]['output errors'],
"collisions" => $data[$itf['if']]['collisions'],
"name" => !empty($itf['descr']) ? $itf['descr'] : $interfaceKey
];
}
}
return $result;
}
echo json_encode($result);
if (isset($argv[1])) {
$intfs = legacy_config_get_interfaces(["virtual" => false]);
$prev = legacy_interface_stats();
while (1) {
$interfaces = $tmp = legacy_interface_stats();
$keys = [
'bytes received',
'bytes transmitted',
'packets received',
'packets transmitted',
'input errors',
'output errors',
'collisions'
];
foreach ($intfs as $interfaceKey => $itf) {
if (array_key_exists($itf['if'], $interfaces) && array_key_exists($itf['if'], $prev)) {
foreach ($keys as $key) {
$tmp[$itf['if']][$key] -= $prev[$itf['if']][$key];
}
}
}
$result = map_ifs($intfs, $tmp);
$prev = $interfaces;
echo 'event: message' . PHP_EOL;
echo 'data: ' . json_encode($result) . PHP_EOL . PHP_EOL;
flush();
sleep($argv[1] <= 1 ? 1 : $argv[1]);
}
} else {
$result = array("interfaces" => array());
$interfaces = legacy_interface_stats();
$temp = gettimeofday();
$result['time'] = (double)$temp["sec"] + (double)$temp["usec"] / 1000000.0;
// collect user friendly interface names
foreach (legacy_config_get_interfaces(array("virtual" => false)) as $interfaceKey => $itf) {
if (array_key_exists($itf['if'], $interfaces)) {
$result['interfaces'][$interfaceKey] = $interfaces[$itf['if']];
$result['interfaces'][$interfaceKey]['name'] = !empty($itf['descr']) ? $itf['descr'] : $interfaceKey;
}
}
echo json_encode($result);
}

View File

@ -107,6 +107,12 @@ parameters:
type:script_output
message:request traffic stats
[stream.traffic]
command:/usr/local/opnsense/scripts/interfaces/traffic_stats.php
parameters:%s
type:stream_output
message:stream traffic stats
[show.top]
command:/usr/local/opnsense/scripts/interfaces/traffic_top.py
parameters: --interfaces %s

View File

@ -28,6 +28,7 @@
class ResizeObserverWrapper {
_lastWidths = {};
_lastHeights = {};
_observer = null;
_debounce(f, delay = 50, ensure = true) {
// debounce to prevent a flood of calls in a short time
@ -47,7 +48,7 @@ class ResizeObserverWrapper {
}
observe(elements, onSizeChanged, onInitialize) {
const observer = new ResizeObserver(this._debounce((entries) => {
this._observer = new ResizeObserver(this._debounce((entries) => {
if (entries != undefined && entries.length > 0) {
for (const entry of entries) {
const width = entry.contentRect.width;
@ -74,9 +75,13 @@ class ResizeObserverWrapper {
}));
elements.forEach((element) => {
observer.observe(element);
this._observer.observe(element);
});
}
disconnect() {
this._observer.disconnect();
}
}
class WidgetManager {
@ -92,6 +97,7 @@ class WidgetManager {
this.grid = null; // gridstack instance
this.moduleDiff = []; // list of module ids that are allowed, but not currently rendered
this.renderDefaultDashboard = true;
this.resizeObserver = null;
}
async initialize() {
@ -236,6 +242,15 @@ class WidgetManager {
this._onWidgetClose(widgetId);
});
window.onbeforeunload = () => {
if (this.resizeObserver !== null) {
this.resizeObserver.disconnect();
}
for (const id of Object.keys(this.widgetClasses)) {
this._onWidgetClose(id);
}
};
// force the cell height of each widget to the lowest value. The grid will adjust the height
// according to the content of the widget.
this.grid.cellHeight(1);
@ -285,7 +300,8 @@ class WidgetManager {
*/
async _loadDynamicContent() {
// handle dynamic resize of widgets
new ResizeObserverWrapper().observe(
this.resizeObserver = new ResizeObserverWrapper();
this.resizeObserver.observe(
document.querySelectorAll('.widget'),
(elem, width, height) => {
for (const subclass of elem.className.split(" ")) {
@ -335,7 +351,12 @@ class WidgetManager {
let onWidgetTick = widget.onWidgetTick.bind(widget);
await onWidgetTick();
const interval = setInterval(async () => {
await onWidgetTick();
await onWidgetTick().catch((error) => {
console.log('caught');
// The page might be closed while a tick routine was executing,
// in that case, the error is expected and can be ignored.
null;
});
this._updateGrid(this.widgetHTMLElements[widget.id]);
}, widget.tickTimeout);
// store the reference to the tick routine so we can clear it later on widget removal

View File

@ -108,6 +108,8 @@ export default class BaseTableWidget extends BaseWidget {
$table.children('.flextable-row').remove();
this.data = data;
for (const row of data) {
let rowType = Array.isArray(row) && row !== null ? 'flat' : 'nested';
if (rowType === 'flat' && this.options.headerPosition !== 'none') {

View File

@ -33,6 +33,7 @@ export default class Cpu extends BaseWidget {
constructor() {
super();
this.resizeHandles = "e, w";
this.eventSource = null;
}
_createChart(selector, timeSeries) {
@ -85,6 +86,12 @@ export default class Cpu extends BaseWidget {
return $container;
}
onwidgetClose() {
if (this.eventSource !== null) {
this.eventSource.close();
}
}
async onMarkupRendered() {
ajaxGet('/api/diagnostics/cpu_usage/getcputype', {}, (data, status) => {
$('.cpu-type').text(data);
@ -98,11 +105,11 @@ export default class Cpu extends BaseWidget {
this._createChart('cpu-usage-intr', intr_ts);
this._createChart('cpu-usage-user', user_ts);
this._createChart('cpu-usage-sys', sys_ts);
const eventSource = new EventSource('/api/diagnostics/cpu_usage/stream');
this.eventSource = new EventSource('/api/diagnostics/cpu_usage/stream');
eventSource.onmessage = function(event) {
this.eventSource.onmessage = function(event) {
if (!event) {
eventSource.close();
this.eventSource.close();
}
const data = JSON.parse(event.data);
let date = Date.now();
@ -110,12 +117,7 @@ export default class Cpu extends BaseWidget {
intr_ts.append(date, data.intr);
user_ts.append(date, data.user);
sys_ts.append(date, data.sys);
}
eventSource.onerror = function(event) {
eventSource.close();
};
}
onWidgetResize(elem, width, height) {

View File

@ -0,0 +1,231 @@
// endpoint:/api/diagnostics/traffic/*
/**
* 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 Traffic extends BaseWidget {
constructor() {
super();
this.charts = {
trafficIn: null,
trafficOut: null
};
this.initialized = false;
this.eventSource = null;
this.datasets = {inbytes: [], outbytes: []};
}
getMarkup() {
return $(`
<div class="traffic-chart-container">
<h3>${this.translations.trafficin}</h3>
<div class="canvas-container">
<canvas id="traffic-in"></canvas>
</div>
<h3>${this.translations.trafficout}</h3>
<div class="canvas-container">
<canvas id="traffic-out"></canvas>
</div>
</div>
`);
}
onWidgetClose() {
this.charts.trafficIn.destroy();
this.charts.trafficOut.destroy();
if (this.eventSource !== null) {
this.eventSource.close();
}
}
_set_alpha(color, opacity) {
const op = Math.round(Math.min(Math.max(opacity || 1, 0), 1) * 255);
return color + op.toString(16).toUpperCase();
}
_formatField(value) {
if (!isNaN(value) && value > 0) {
let fileSizeTypes = ["", "K", "M", "G", "T", "P", "E", "Z", "Y"];
let ndx = Math.floor(Math.log(value) / Math.log(1000) );
if (ndx > 0) {
return (value / Math.pow(1000, ndx)).toFixed(2) + ' ' + fileSizeTypes[ndx];
} else {
return value.toFixed(2);
}
} else {
return "";
}
}
_chartConfig(dataset) {
return {
type: 'line',
data: {
datasets: dataset
},
options: {
bezierCurve: false,
maintainAspectRatio: false,
scaleShowLabels: false,
tooltipEvents: [],
pointDot: false,
scaleShowGridLines: true,
responsive: true,
elements: {
line: {
fill: true,
cubicInterpolationMode: 'monotone',
clip: 0
}
},
scales: {
x: {
display: false,
time: {
tooltipFormat:'HH:mm:ss',
unit: 'second',
stepSize: 10,
minUnit: 'second',
displayFormats: {
second: 'HH:mm:ss',
minute: 'HH:mm:ss'
}
},
type: 'realtime',
realtime: {
duration: 20000,
delay: 1000,
},
},
y: {
ticks: {
callback: (value, index, values) => {
return this._formatField(value);
}
}
}
},
hover: {
mode: 'nearest',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'nearest',
intersect: false,
callbacks: {
label: (context) => {
return context.dataset.label + ": " + this._formatField(context.dataset.data[context.dataIndex].y).toString();
}
}
},
streaming: {
frameRate: 30,
ttl: 30000
},
colorschemes: {
scheme: 'tableau.Classic10'
}
}
},
};
}
onMessage(event) {
if (!event) {
this.eventSource.close();
}
const data = JSON.parse(event.data);
if (!this.initialized) {
// prepare datasets
for (const dir of ['inbytes', 'outbytes']) {
let colors = Chart.colorschemes.tableau.Classic10;
let i = 0;
Object.keys(data.interfaces).forEach((intf) => {
let idx = i % colors.length;
i++;
this.datasets[dir].push({
label: data.interfaces[intf].name,
hidden: false, // XXX
borderColor: colors[idx],
backgroundColor: this._set_alpha(colors[idx], 0.5),
pointHoverBackgroundColor: colors[idx],
pointHoverBorderColor: colors[idx],
pointBackgroundColor: colors[idx],
pointBorderColor: colors[idx],
intf: intf,
last_time: data.time,
src_field: dir,
data: [],
});
});
}
this.initialized = true;
return;
}
for (let chart of Object.values(this.charts)) {
if (chart !== null) {
Object.keys(data.interfaces).forEach((intf) => {
chart.config.data.datasets.forEach((dataset) => {
if (dataset.intf === intf) {
let elapsed_time = data.time - dataset.last_time;
dataset.data.push({
x: Date.now(),
y: Math.round(((data.interfaces[intf][dataset.src_field]) / elapsed_time) * 8, 0)
});
dataset.last_time = data.time;
return;
}
});
});
chart.update();
}
}
}
async onMarkupRendered() {
let $trafficIn = $('#traffic-in');
let $trafficOut = $('#traffic-out');
let trafficInCtx = $trafficIn[0].getContext('2d');
let trafficOutCtx = $trafficOut[0].getContext('2d');
this.eventSource = new EventSource('/api/diagnostics/traffic/stream/1');
this.eventSource.onmessage = this.onMessage.bind(this);
this.charts.trafficIn = new Chart(trafficInCtx, this._chartConfig(this.datasets.inbytes));
this.charts.trafficOut = new Chart(trafficOutCtx, this._chartConfig(this.datasets.outbytes));
}
}