System: Health: refactor to Chart.js (#8258)

This commit is contained in:
Stephan de Wit 2025-01-30 14:02:41 +01:00 committed by GitHub
parent 695772d201
commit 98464bab9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 448 additions and 602 deletions

4
plist
View File

@ -2054,6 +2054,8 @@
/usr/local/opnsense/www/js/chartjs-plugin-colorschemes.min.js
/usr/local/opnsense/www/js/chartjs-plugin-matrix.min.js
/usr/local/opnsense/www/js/chartjs-plugin-streaming.js
/usr/local/opnsense/www/js/chartjs-plugin-zoom.min.js
/usr/local/opnsense/www/js/chartjs-scale-timestack.min.js
/usr/local/opnsense/www/js/d3.min.js
/usr/local/opnsense/www/js/d3.min.js.LICENSE
/usr/local/opnsense/www/js/gridstack-all.min.js
@ -2063,12 +2065,14 @@
/usr/local/opnsense/www/js/jquery.bootgrid.js
/usr/local/opnsense/www/js/jquery.qrcode.js
/usr/local/opnsense/www/js/jquery.qrcode.js.LICENSE
/usr/local/opnsense/www/js/luxon.min.js
/usr/local/opnsense/www/js/moment-with-locales.min.js
/usr/local/opnsense/www/js/nv.d3.min.js
/usr/local/opnsense/www/js/nv.d3.min.js.LICENSE.md
/usr/local/opnsense/www/js/opnsense-treeview.js
/usr/local/opnsense/www/js/opnsense.js
/usr/local/opnsense/www/js/opnsense_bootgrid_plugin.js
/usr/local/opnsense/www/js/opnsense_health.js
/usr/local/opnsense/www/js/opnsense_status.js
/usr/local/opnsense/www/js/opnsense_theme.js
/usr/local/opnsense/www/js/opnsense_ui.js

View File

@ -114,6 +114,9 @@ class SystemhealthController extends ApiControllerBase
/**
* retrieve SystemHealth Data (previously called RRD Graphs)
*
* XXX: $inverse to be removed for 25.7
*
* @param string $rrd
* @param bool $inverse
* @param int $detail
@ -131,18 +134,10 @@ class SystemhealthController extends ApiControllerBase
$response['set']['count'] = count($records);
$response['set']['step_size'] = $response['sets'][$detail]['step_size'];
foreach ($records as $key => $record) {
$record['area'] = true;
if ($inverse && $key % 2 != 0) {
foreach ($record['values'] as &$value) {
$value[1] = $value[1] * -1;
}
}
$response['set']['data'][] = $record;
}
}
for ($i = 0; $i < count($response['sets']); $i++) {
unset($response['sets'][$detail]['ds']);
}
unset($response['sets']);
if (!empty($rrd_details["title"])) {
$response['title'] = $rrd_details["title"] . " | " . ucfirst($rrd_details['itemName']);
} else {
@ -151,7 +146,7 @@ class SystemhealthController extends ApiControllerBase
$response['y-axis_label'] = $rrd_details["y-axis_label"];
return $response;
} else {
return ["sets" => [], "set" => [], "title" => "error", "y-axis_label" => ""];
return ["set" => [], "title" => "error", "y-axis_label" => ""];
}
}
@ -171,4 +166,31 @@ class SystemhealthController extends ApiControllerBase
}
return $intfmap;
}
public function exportAsCSVAction($rrd = "", $detail = -1)
{
$data = $this->getSystemHealthAction($rrd, 0, $detail);
if (empty($data['set']['data'])) {
return;
}
$parsed = [];
$numKeys = count($data['set']['data']);
$length = count($data['set']['data'][0]['values']);
for ($i = 0; $i < $length; $i++) {
$timestamp = $data['set']['data'][0]['values'][$i][0] / 1000;
$part = [
"iso_time" => date("c", $timestamp)
];
$values = [];
for ($j = 0; $j < $numKeys; $j++) {
$values[$data['set']['data'][$j]['key']] = $data['set']['data'][$j]['values'][$i][1];
}
$parsed[] = array_merge($part, $values);
}
$this->exportCsv($parsed);
}
}

View File

@ -2,6 +2,7 @@
/*
* Copyright (C) 2015 Jos Schellevis <jos@opnsense.org>
* Copyright (C) 2025 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without

View File

@ -1,623 +1,187 @@
<!--
/*
* Copyright (C) 2023 Deciso B.V.
* Copyright (C) 2015 Jos Schellevis <jos@opnsense.org>
* 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.
*/
-->
{#
<style type="text/css">
.panel-heading-sm{
height: 28px;
padding: 4px 10px;
}
</style>
OPNsense® is Copyright © 2025 by Deciso B.V.
All rights reserved.
<!-- nvd3 -->
<link rel="stylesheet" type="text/css" href="{{ cache_safe(theme_file_or_default('/css/nv.d3.css', ui_theme|default('opnsense'))) }}" />
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
<!-- d3 -->
<script src="{{ cache_safe('/ui/js/d3.min.js') }}"></script>
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
<!-- nvd3 -->
<script src="{{ cache_safe('/ui/js/nv.d3.min.js') }}"></script>
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.
<!-- System Health -->
<style>
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.
#chart svg {
height: 500px;
}
</style>
#}
<script src="{{ cache_safe('/ui/js/chart.umd.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chartjs-plugin-colorschemes.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/moment-with-locales.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chartjs-adapter-moment.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chartjs-plugin-zoom.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/luxon.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/chartjs-scale-timestack.min.js') }}"></script>
<script src="{{ cache_safe('/ui/js/opnsense_health.js') }}"></script>
<script>
let chart;
let data = [];
let disabled = [];
let resizeTimer;
let current_detail = 0;
let csvData = [];
let zoom_buttons;
let rrd="";
$(document).ready(function() {
const healthGraph = new HealthGraph('health-chart');
$('#spinner').show();
healthGraph.initialize().then(async () => {
const rrdOptions = healthGraph.getRRDList();
for (const [category, subitems] of Object.entries(rrdOptions.data)) {
// create our chart
nv.addGraph(function () {
chart = nv.models.lineWithFocusChart()
.margin( {left:70})
.x(function (d) {
return d[0]
})
.y(function (d) {
return d[1]
let $select = $('#health-category-select');
let $option = $('<option>', {
value: category,
text: category[0].toUpperCase() + category.slice(1)
});
chart.xAxis
.tickFormat(function (d) {
return d3.time.format('%b %e %H:%M')(new Date(d))
$option.appendTo($select);
}
$('#health-category-select').on('changed.bs.select', async function() {
$subselect = $('#health-subcategory-select');
$subselect.empty();
const selectedCategory = $(this).val();
rrdOptions.data[selectedCategory].forEach((sub) => {
let optionText = sub;
if (sub in rrdOptions.interfaces) {
optionText = rrdOptions.interfaces[sub].descr;
}
let $option = $('<option>', {
value: sub,
text: optionText,
}).appendTo($subselect);
});
chart.x2Axis
.tickFormat(function (d) {
return d3.time.format('%Y-%m-%d')(new Date(d))
});
chart.yAxis
.tickFormat(d3.format(',.2s'));
chart.y2Axis
.tickFormat(d3.format(',.1s'));
chart.focusHeight(80);
chart.interpolate('step-before');
// dispatch when one of the streams is enabled/disabled
chart.dispatch.on('stateChange', function (e) {
disabled = e['disabled'];
});
// dispatch on window resize - delay action with 500ms timer
nv.utils.windowResize(function () {
if (resizeTimer) {
clearTimeout(resizeTimer);
}
resizeTimer = setTimeout(function () {
chart.update();
resizeTimer = null;
}, 500);
});
return chart;
});
function getRRDlist() {
ajaxGet("/api/diagnostics/systemhealth/getRRDlist/", {}, function (data, status) {
if (status == "success") {
let category;
let tabs = "";
let subitem = "";
let active_category = Object.keys(data["data"])[0];
let active_subitem = data["data"][active_category][0];
let rrd_name = "";
for ( category in data["data"]) {
if (category == active_category) {
tabs += '<li role="presentation" class="dropdown active">';
} else {
tabs += '<li role="presentation" class="dropdown">';
}
subitem = data["data"][category][0]; // first sub item
rrd_name = subitem + '-' + category;
// create dropdown menu
tabs+='<a data-toggle="dropdown" href="#" class="dropdown-toggle pull-right visible-lg-inline-block visible-md-inline-block visible-xs-inline-block visible-sm-inline-block" role="button">';
tabs+='<b><span class="caret"></span></b>';
tabs+='</a>';
tabs+='<a data-toggle="tab" onclick="$(\'#'+rrd_name+'\').click();" class="visible-lg-inline-block visible-md-inline-block visible-xs-inline-block visible-sm-inline-block" style="border-right:0px;"><b>'+category[0].toUpperCase() + category.slice(1)+'</b></a>';
tabs+='<ul class="dropdown-menu" role="menu">';
// add subtabs
for (let count=0; count<data["data"][category].length;++count ) {
subitem=data["data"][category][count];
rrd_name = subitem + '-' + category;
if (subitem==active_subitem && category==active_category) {
tabs += '<li class="active"><a data-toggle="tab" class="rrd_item" id="'+rrd_name+'">' + subitem[0].toUpperCase() + subitem.slice(1) + '</a></li>';
} else {
tabs += '<li><a data-toggle="tab" class="rrd_item" id="'+rrd_name+'">' + subitem[0].toUpperCase() + subitem.slice(1) + '</a></li>';
}
}
tabs+='</ul>';
tabs+='</li>';
}
$('#maintabs').html(tabs);
$('#tab_1').toggleClass('active');
// map interface descriptions
$(".rrd_item").each(function(){
let rrd_item = $(this);
let rrd_item_name = $(this).attr('id').split('-')[0].toLowerCase();
$.map(data['interfaces'], function(value, key){
if (key.toLowerCase() == rrd_item_name) {
rrd_item.html(value['descr']);
}
});
});
$(".rrd_item").click(function(){
// switch between rrd graphs
$('#zoom').empty();
disabled = []; // clear disabled stream data
chart.brushExtent([0, 0]); // clear focus area
getdata($(this).attr('id'));
});
$(".update_options").change(function(){
window.onresize = null; // clear any pending resize events
let rrd = $(".dropdown-menu > li.active > a").attr('id');
if ($(this).attr('id') == 'zoom') {
chart.brushExtent([0, 0]); // clear focus area
}
getdata(rrd);
});
$("#"+active_subitem+"-"+active_category).click();
}
});
}
function getdata(rrd_name) {
let from = 0;
let to = 0;
let maxitems = 120;
let detail = $('input:radio[name=detail]:checked').val() ?? '0';
let inverse = $('input:radio[name=inverse]:checked').val() == 1 ? '1' : '0';
// array used for cvs export option
csvData = [];
// array used for min/max/average table when shown
let min_max_average = {};
// info bar - hide averages info bar while refreshing data
$('#averages').hide();
$('#chart_title').hide();
// info bar - show loading info bar while refreshing data
$('#loading').show();
// API call to request data
ajaxGet("/api/diagnostics/systemhealth/getSystemHealth/" + rrd_name + "/" + inverse + "/" + detail, {}, function (data, status) {
if (status == "success") {
let stepsize = data["set"]["step_size"];
let scale = "{{ lang._('seconds') }}";
let dtformat = '%m-%d %H:%M';
// set defaults based on stepsize
if (stepsize >= 86400) {
stepsize = stepsize / 86400;
scale = "{{ lang._('days') }}";
dtformat = '\'%y w%U%';
} else if (stepsize >= 3600) {
stepsize = stepsize / 3600;
scale = "{{ lang._('hours') }}";
dtformat = '\'%y d%j%';
} else if (stepsize >= 60) {
stepsize = stepsize / 60;
scale = "{{ lang._('minutes') }}";
dtformat = '%H:%M';
}
// Add zoomlevel buttons/options
if ($('input:radio[name=detail]:checked').val() == undefined) {
$('#zoom').html("<b>No data available</b>");
for (let setcount = 0; setcount < data["sets"].length; ++setcount) {
const set_stepsize = data["sets"][setcount]["step_size"];
let detail_text = '';
// Find out what text matches best
if (set_stepsize >= 31536000) {
detail_text = Math.floor(set_stepsize / 31536000).toString() + " {{ lang._('Year(s)') }}";
} else if (set_stepsize >= 259200) {
detail_text = Math.floor(set_stepsize / 86400).toString() + " {{ lang._('Days') }}";
} else if (set_stepsize > 3600) {
detail_text = Math.floor(set_stepsize / 3600).toString() + " {{ lang._('Hours') }}";
} else {
detail_text = Math.floor(set_stepsize / 60).toString() + " {{ lang._('Minute(s)') }}";
}
if (setcount == 0) {
$('#zoom').empty();
$('#zoom').append('<label class="btn btn-default active"> <input type="radio" id="d' + setcount.toString() + '" name="detail" checked="checked" value="' + setcount.toString() + '" /> ' + detail_text + ' </label>');
} else {
$('#zoom').append('<label class="btn btn-default"> <input type="radio" id="d' + setcount.toString() + '" name="detail" value="' + setcount.toString() + '" /> ' + detail_text + ' </label>');
}
}
}
$('#stepsize').text(stepsize + " " + scale);
// Check for enabled or disabled stream, to make sure that same set stays selected after update
for (let index = 0; index < disabled.length; ++index) {
window.resize = null;
data["set"]["data"][index]["disabled"] = disabled[index]; // disable stream if it was disabled before updating dataset
}
// Create tables (general and detail)
if ($('input:radio[name=show_table]:checked').val() == 1) { // check if toggle table is on
// Setup variables for table data
let table_head; // used for table headings in html format
let table_row_data = {}; // holds row data for table
let table_view_rows = ""; // holds row data in html format
let keyname = ""; // used for name of key
let rowcounter = 0;// general row counter
let min; // holds calculated minimum value
let max; // holds calculated maximum value
let average; // holds calculated average
let t; // general date/time variable
let item; // used for name of key
let counter = 1; // used for row count
table_head = "<th>#</th>";
if ($('input:radio[name=toggle_time]:checked').val() == 1) {
table_head += "<th>{{ lang._('full date & time') }}</th>";
} else {
table_head += "<th>{{ lang._('timestamp') }}</th>";
}
for (let index = 0; index < data["set"]["data"].length; ++index) {
rowcounter = 0;
min = 0;
max = 0;
average = 0;
if (data["set"]["data"][index]["disabled"] != true) {
table_head += '<th>' + data["set"]["data"][index]["key"] + '</th>';
keyname = data["set"]["data"][index]["key"].toString();
for (let value_index = 0; value_index < data["set"]["data"][index]["values"].length; ++value_index) {
if (data["set"]["data"][index]["values"][value_index][0] >= (from * 1000) && data["set"]["data"][index]["values"][value_index][0] <= (to * 1000) || ( from == 0 && to == 0 )) {
if (table_row_data[data["set"]["data"][index]["values"][value_index][0]] === undefined) {
table_row_data[data["set"]["data"][index]["values"][value_index][0]] = {};
}
if (table_row_data[data["set"]["data"][index]["values"][value_index][0]][data["set"]["data"][index]["key"]] === undefined) {
table_row_data[data["set"]["data"][index]["values"][value_index][0]][data["set"]["data"][index]["key"]] = data["set"]["data"][index]["values"][value_index][1];
}
if (csvData[rowcounter] === undefined) {
csvData[rowcounter] = {};
}
if (csvData[rowcounter]["timestamp"] === undefined) {
t = new Date(parseInt(data["set"]["data"][index]["values"][value_index][0]));
csvData[rowcounter]["timestamp"] = data["set"]["data"][index]["values"][value_index][0] / 1000;
csvData[rowcounter]["date_time"] = t.toString();
}
csvData[rowcounter][keyname] = data["set"]["data"][index]["values"][value_index][1];
if (data["set"]["data"][index]["values"][value_index][1] < min) {
min = data["set"]["data"][index]["values"][value_index][1];
}
if (data["set"]["data"][index]["values"][value_index][1] > max) {
max = data["set"]["data"][index]["values"][value_index][1];
}
average += data["set"]["data"][index]["values"][value_index][1];
++rowcounter;
}
}
if (min_max_average[keyname] === undefined) {
min_max_average[keyname] = {};
min_max_average[keyname]["min"] = min;
min_max_average[keyname]["max"] = max;
min_max_average[keyname]["average"] = average / rowcounter;
}
}
}
for ( item in min_max_average) {
table_view_rows += "<tr>";
table_view_rows += "<td>" + item + "</td>";
table_view_rows += "<td>" + min_max_average[item]["min"].toString() + "</td>";
table_view_rows += "<td>" + min_max_average[item]["max"].toString() + "</td>";
table_view_rows += "<td>" + min_max_average[item]["average"].toString() + "</td>";
table_view_rows += "</tr>";
}
$('#table_view_general_heading').html('<th>item</th><th>min</th><th>max</th><th>average</th>');
$('#table_view_general_rows').html(table_view_rows);
table_view_rows = "";
for ( item in table_row_data) {
if ($('input:radio[name=toggle_time]:checked').val() == 1) {
t = new Date(parseInt(item));
table_view_rows += "<tr><td>" + counter.toString() + "</td><td>" + t.toString() + "</td>";
} else {
table_view_rows += "<tr><td>" + counter.toString() + "</td><td>" + parseInt(item / 1000).toString() + "</td>";
}
for (let value in table_row_data[item]) {
table_view_rows += "<td>" + table_row_data[item][value] + "</td>";
}
++counter;
table_view_rows += "</tr>";
}
$('#table_view_heading').html(table_head);
$('#table_view_rows').html(table_view_rows);
$('#chart_details_table').show();
$('#chart_general_table').show();
} else {
$('#chart_details_table').hide();
$('#chart_general_table').hide();
}
chart.xAxis
.tickFormat(function (d) {
return d3.time.format(dtformat)(new Date(d))
});
chart.yAxis.axisLabel(data["y-axis_label"]);
chart.forceY([0]);
chart.useInteractiveGuideline(true);
chart.interactive(true);
d3.select('#chart svg')
.datum(data["set"]["data"])
.transition().duration(0)
.call(chart);
chart.update();
window.onresize = null; // clear any pending resize events
$('#loading').hide(); // Data has been found and chart will be drawn
$('#averages').show();
$('#chart_title').show();
if (data["title"]!="") {
$('#chart_title').show();
$('#chart_title').text(data["title"]);
} else
{
$('#chart_title').hide();
}
} else {
$('#loading').hide();
$('#chart_title').show();
$('#chart_title').text("{{ lang._('Unable to load data') }}");
}
});
}
// convert a data Array to CSV format
function convertToCSV(args) {
let result, ctr, keys, columnDelimiter, lineDelimiter, data;
data = args.data || null;
if (data == null || !data.length) {
return null;
}
columnDelimiter = args.columnDelimiter || ';';
lineDelimiter = args.lineDelimiter || '\n';
keys = Object.keys(data[0]);
result = '';
result += keys.join(columnDelimiter);
result += lineDelimiter;
data.forEach(function (item) {
ctr = 0;
keys.forEach(function (key) {
if (ctr > 0) result += columnDelimiter;
result += item[key];
ctr++;
$('#health-subcategory-select').selectpicker('refresh');
// trigger first selection
$('#health-subcategory-select').val(rrdOptions.data[selectedCategory][0]).trigger('changed.bs.select');
});
result += lineDelimiter;
});
return result;
}
// download CVS file
function downloadCSV(args) {
let data, filename, link;
let csv = convertToCSV({
data: csvData
});
if (csv == null) return;
filename = args.filename || 'export.csv';
$('#health-subcategory-select').on('changed.bs.select', async function() {
$('#spinner').show();
let sub = $(this).val();
let category = $('#health-category-select').val();
let system = `${sub}-${category}`;
await healthGraph.update(system);
$('#spinner').hide();
});
if (!csv.match(/^data:text\/csv/i)) {
csv = 'data:text/csv;charset=utf-8,' + csv;
}
data = encodeURI(csv);
$('.selectpicker').selectpicker('refresh');
link = document.createElement('a');
link.href = data;
link.target = '_blank';
link.download = filename;
document.body.appendChild(link);
link.click();
}
// trigger event for first category
$('#health-category-select').val(Object.keys(rrdOptions.data)[0]).trigger('changed.bs.select');
$(document).ready(function() {
$("#options").collapse('show');
// hide title row
$(".page-content-head").addClass("hidden");
// Load data when document is ready
getRRDlist();
});
$("#reset-zoom").click(function() {
healthGraph.resetZoom();
});
$("#export").click(function() {
healthGraph.exportData();
})
$('#detail-select').change(async function() {
$('#spinner').show();
var selectedValue = $(this).val();
await healthGraph.update(null, selectedValue);
$('#spinner').hide();
});
}).catch((err) => {
$('#info-disabled').show();
$('#main').hide();
});
});
</script>
<div class="tab-content">
<div id="info_tab" class="tab-pane fade in">
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
<b>{{ lang._('Information') }}</b>
</h3>
</div>
<div class="panel-body">
{{ lang._('Local data collection is not enabled at the moment') }}
<a href="/reporting_settings.php">{{ lang._('Go to reporting settings') }} </a>
</div>
</div>
</div>
<div id="tab_1" class="tab-pane fade in">
<div class="panel panel-primary">
<div class="panel-heading panel-heading-sm">
<i class="fa fa-chevron-down" style="cursor: pointer;" data-toggle="collapse" data-target="#options"></i>
<b>{{ lang._('Options') }}</b>
</div>
<div class="panel-body collapse" id="options">
<div class="container-fluid">
<ul class="nav nav-tabs" role="tablist" id="maintabs">
{# Tab Content #}
</ul>
<div class="row">
<div class="col-md-12"></div>
<div class="col-md-4">
<b>{{ lang._('Granularity') }}:</b>
<div class="btn-group btn-group-xs update_options" data-toggle="buttons" id="zoom">
<!-- The zoom buttons are generated based upon the current dataset -->
</div>
</div>
<div class="col-md-2">
<b>{{ lang._('Inverse') }}:</b>
<div class="btn-group btn-group-xs update_options" data-toggle="buttons">
<label class="btn btn-default active">
<input type="radio" id="in0" name="inverse" checked="checked" value="0"/>
{{lang._('Off') }}
</label>
<label class="btn btn-default">
<input type="radio" id="in1" name="inverse" value="1"/> {{ lang._('On') }}
</label>
</div>
</div>
<div class="col-md-4">
</div>
<div class="col-md-2">
<b>{{ lang._('Show Tables') }}:</b>
<div class="btn-group btn-group-xs update_options" data-toggle="buttons">
<label class="btn btn-default active">
<input type="radio" id="tab0" name="show_table" checked="checked" value="0"/> {{
lang._('Off') }}
</label>
<label class="btn btn-default">
<input type="radio" id="tab1" name="show_table" value="1"/> {{ lang._('On') }}
</label>
</div>
</div>
</div>
</div>
</div>
<style>
.centered {
display: flex;
justify-content: center;
align-items: center;
}
.label-select-pair {
margin: 0 15px;
text-align: center;
}
.label-select-pair label {
display: block;
margin-bottom: 5px;
}
.spinner-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 32px;
}
.mb-2 {
margin-bottom: 0.5rem;
}
</style>
<div class="panel panel-default">
<div id="info-disabled" class="alert alert-warning" role="alert" style="display: none;">
{{ lang._('Local data collection is not enabled. Enable it in Reporting Settings page.') }}
<br />
<a href="/reporting_settings.php">{{ lang._('Go to the Reporting configuration') }}</a>
</div>
<div id="health-header" class="panel-heading centered">
<button id="reset-zoom" class="btn btn-primary" style="align-self: flex-end;">Reset zoom</button>
<div class="label-select-pair">
<label for="health-category-select"><b>{{ lang._('Category') }}</b></label>
<select id="health-category-select" class="selectpicker" data-width="200px" data-container="body"></select>
</div>
<div class="label-select-pair">
<label for="health-subcategory-select"><b>{{ lang._('Subject') }}</b></label>
<select id="health-subcategory-select" class="selectpicker" data-width="200px" data-live-search="true" data-container="body"></select>
</div>
<!-- place holder for the chart itself -->
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
<span id="loading">
<i id="loading" class="fa fa-spinner fa-spin"></i>
<b>{{ lang._('Please wait while loading data...') }}</b>
</span>
<span id="chart_title"> </span>
<span id="averages">
<small>
(<b>{{ lang._('Current detail is showing') }} <span id="stepsize"></span> {{ lang._('averages') }}.</b>)
</small>
</span>
</h3>
</div>
<div class="panel-body">
<div id="chart">
<svg></svg>
</div>
</div>
<div class="label-select-pair ">
<label for="detail-select"><b>{{ lang._('Granularity') }}</b></label>
<select id="detail-select" class="selectpicker" data-width="200px">
<option value="0">{{ lang._('Default (%d minute)') | format('1') }}</option>
<option value="1">{{ lang._('%d minutes') | format('5') }}</option>
<option value="2">{{ lang._('%d hour') | format('1') }}</option>
<option value="3">{{ lang._('%d hours') | format('24') }}</option>
</select>
</div>
<!-- place holder for the general table with min/max/averages, is hidden by default -->
<div id="chart_general_table" class="col-md-12" style="display: none;">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"> {{ lang._('Current View - Overview') }}</h3>
</div>
<div class="panel-body">
<div class="table-responsive">
<table class="table table-condensed table-hover table-striped">
<thead>
<tr id="table_view_general_heading" class="active">
<!-- Dynamic data -->
</tr>
</thead>
<tbody id="table_view_general_rows">
<!-- Dynamic data -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<button id="export" class="btn btn-default" data-toggle="tooltip" data-original-title="{{ lang._('Export current selection as CSV')}}" style="align-self: flex-end;">
<span class="fa fa-cloud-download"></span>
</button>
</div>
<div id="chart_details_table" class="col-md-12" style="display: none;">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{{ lang._('Current View - Details') }}
</h3>
</div>
<div class="panel-body">
<div class="btn-toolbar" role="toolbar">
<i>{{ lang._('Toggle Timeview') }}:</i>
<div id="main" class="panel-body">
<div class="chart-container" style="position: relative; height: 60vh;">
<canvas id="health-chart"></canvas>
<i id="spinner" class="fa fa-spinner fa-pulse spinner-overlay" style="display: none;"></i>
</div>
</div>
<div class="btn-group update_options" data-toggle="buttons">
<label class="btn btn-xs btn-default active">
<input type="radio" id="time0" name="toggle_time" checked="checked" value="0"/> {{
lang._('Timestamp') }}
</label>
<label class="btn btn-xs btn-default">
<input type="radio" id="time1" name="toggle_time" value="1"/> {{ lang._('Full Date & Time') }}
</label>
</div>
<div class="btn btn-xs btn-primary inline" onclick='downloadCSV({ filename: rrd+".csv" });'>
<i class="fa fa-download"></i> {{ lang._('Download as CSV') }}
</div>
</div>
<div class="table-responsive">
<table class="table table-condensed table-hover table-striped">
<thead>
<tr id="table_view_heading" class="active">
<!-- Dynamic data -->
</tr>
</thead>
<tbody id="table_view_rows">
<!-- Dynamic data -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="panel-footer">
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
src/opnsense/www/js/luxon.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,246 @@
/*
* Copyright (C) 2025 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.
*/
class HealthGraph {
constructor(canvasId, chartConfig = {}, datasetOptions = {}) {
this.ctx = $(`#${canvasId}`)[0].getContext('2d');
this.chartConfig = { ...this._defaultChartOptions(), ...chartConfig };
this.datasetOptions = { ...this._defaultDatasetOptions(), ...datasetOptions };
this.chart = null;
this.rrdList = null;
this.currentSystem = null;
this.currentDetailLevel = 0;
}
async initialize() {
this.rrdList = await this._fetchRRDList();
if (Object.keys(this.rrdList.data).length === 0) {
throw new Error('No RRD data available');
}
const firstKey = Object.keys(this.rrdList.data)[0];
const firstValue = this.rrdList.data[firstKey]?.[0] || "";
this.currentSystem = `${firstValue}-${firstKey}`;
this.chart = new Chart(this.ctx, this.chartConfig);
}
getRRDList() {
return this.rrdList;
}
async update(system = null, detailLevel = null) {
if (system === null)
system = this.currentSystem;
else
this.currentSystem = system;
if (detailLevel === null)
detailLevel = this.currentDetailLevel;
else
this.currentDetailLevel = detailLevel;
const data = await this._fetchData();
const formatted = this._formatData(data.set);
const stepSize = data.set.step_size;
this.chart.data.datasets = formatted;
this.chart.options.scales.y.title.text = data['y-axis_label'];
this.chart.options.plugins.title.text = data['title'];
this.chart.options.plugins.zoom.limits.x.minRange = this._getMinRange(stepSize * 1000);
this.chart.update();
this.chart.resetZoom();
}
resetZoom() {
this.chart.resetZoom();
}
exportData() {
window.open(`/api/diagnostics/systemhealth/exportAsCSV/${this.currentSystem}/${this.currentDetailLevel}`);
}
async _fetchRRDList() {
const list = await fetch(`/api/diagnostics/systemhealth/getRRDlist`).then(response => response.json());
return list;
}
async _fetchData() {
const data = await fetch(`/api/diagnostics/systemhealth/getSystemHealth/${this.currentSystem}/0/${this.currentDetailLevel}`)
.then(response => response.json());
return data;
}
_getMinRange(stepSize) {
const ONE_MINUTE = 60 * 1000;
const FIVE_MINUTES = 5 * ONE_MINUTE;
const ONE_HOUR = 60 * ONE_MINUTE;
const ONE_DAY = 24 * ONE_HOUR;
if (stepSize <= ONE_MINUTE) {
return 5 * ONE_MINUTE;
} else if (stepSize <= FIVE_MINUTES) {
return 30 * ONE_MINUTE;
} else if (stepSize <= ONE_HOUR) {
return 6 * ONE_HOUR;
} else if (stepSize <= ONE_DAY) {
return 7 * ONE_DAY;
} else {
return 30 * ONE_DAY;
}
}
_formatData(data) {
let datasets = [];
for (const item of data.data) {
const dataset = {
...this.datasetOptions,
label: item.key,
data: []
};
for (const [x, y] of item.values) {
// timestack formatter expects array of x,y objects
dataset.data.push({ x: x, y: y });
}
datasets.push(dataset);
}
return datasets;
}
_defaultChartOptions() {
const verticalHoverLine = {
id: 'verticalHoverLine',
beforeDatasetsDraw(chart, args, pluginOptions) {
if (chart.getDatasetMeta(0).data.length == 0) {
// data may not have loaded yet
return;
}
const { ctx, chartArea: { top, bottom } } = chart;
ctx.save();
chart.getDatasetMeta(0).data.forEach((dataPoint, index) => {
if (dataPoint.active === true) {
ctx.beginPath();
ctx.strokeStyle = 'gray'; // XXX theming concern
ctx.moveTo(dataPoint.x, top);
ctx.lineTo(dataPoint.x, bottom);
ctx.stroke();
}
});
}
};
const config = {
type: 'line',
data: {},
options: {
normalized: true,
responsive: true,
maintainAspectRatio: false,
parsing: false,
animation: {
duration: 500,
},
scales: {
x: {
type: 'timestack'
},
y: {
type: 'linear',
position: 'left',
title: {
display: true,
padding: 8
}
},
},
plugins: {
zoom: {
limits: {
x: { min: 'original', max: 'original', minRange: 1800 * 1000 },
},
pan: {
enabled: true,
mode: 'x',
modifierKey: 'ctrl',
},
zoom: {
wheel: {
speed: 0.3,
enabled: true,
},
drag: {
enabled: true,
},
pinch: {
enabled: true
},
mode: 'x',
}
},
title: {
display: true,
position: 'top',
},
tooltip: {
caretPadding: 15,
},
},
transitions: {
zoom: {
animation: {
duration: 500
}
}
},
interaction: {
// complementary to the vertical line on hover plugin
mode: 'index',
intersect: false
}
},
plugins: [verticalHoverLine]
};
return config;
}
_defaultDatasetOptions() {
return {
spanGaps: true,
pointRadius: 0,
pointHoverRadius: 7,
stepped: true,
pointHoverBackgroundColor: (ctx) => ctx.element.options.borderColor,
}
}
}