dashboard: traffic widget
This commit is contained in:
parent
354e964d70
commit
5a21f67030
2
plist
2
plist
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue