mirror of
https://github.com/lucaspalomodevelop/core.git
synced 2026-03-13 00:07:26 +00:00
System: Health: refactor to Chart.js (#8258)
This commit is contained in:
parent
695772d201
commit
98464bab9a
4
plist
4
plist
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
7
src/opnsense/www/js/chartjs-plugin-zoom.min.js
vendored
Normal file
7
src/opnsense/www/js/chartjs-plugin-zoom.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/opnsense/www/js/chartjs-scale-timestack.min.js
vendored
Normal file
1
src/opnsense/www/js/chartjs-scale-timestack.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/opnsense/www/js/luxon.min.js
vendored
Normal file
1
src/opnsense/www/js/luxon.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
246
src/opnsense/www/js/opnsense_health.js
Normal file
246
src/opnsense/www/js/opnsense_health.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user