/** (C) 2022 - ntop.org */ import formatterUtils from "./formatter-utils"; import colorsInterpolation from "./colors-interpolation"; import { ntopng_utility, ntopng_url_manager } from "../services/context/ntopng_globals_services.js"; const constant_serie_colors = { "95_perc": "#8EA4E8", "avg": "#839BE6", } function getSerieId(serie) { return `${serie.id}`; } function getSerieName(name, id, tsGroup, useFullName) { if (name == null) { name = id; } let name_more_space = ""; if (name != null) { name_more_space = `${name}`; } if (useFullName == false) { return name; } let source_index = getMainSourceDefIndex(tsGroup); let source = tsGroup.source_array[source_index]; let prefix = `${source.label}`; return `${prefix} - ${name_more_space}`; } function getYaxisId(metric) { return `${metric.measure_unit}_${metric.scale}`; } const defaultColors = [ "#C6D9FD", "#90EE90", "#EE8434", "#C95D63", "#AE8799", "#717EC3", "#496DDB", "#5A7ADE", "#6986E1", "#7791E4", "#839BE6", "#8EA4E8", ]; function setSeriesColors(palette_list) { let colors_list = palette_list; let count0 = 0, count1 = 0; let colors0 = defaultColors; let colors1 = d3v7.schemeCategory10; colors_list.forEach((s, index) => { if (s.palette == 0) { palette_list[index] = colors0[count0 % colors0.length]; count0 += 1; } else if (s.palette == 1) { palette_list[index] = colors1[count1 % colors1.length]; count1 += 1; } }); } const groupsOptionsModesEnum = { '1_chart_x_metric': { value: "1_chart_x_metric", label: i18n('page_stats.layout_1_per_1') }, '1_chart_x_yaxis': { value: "1_chart_x_yaxis", label: i18n('page_stats.layout_1_per_y') }, } function getGroupOptionMode(group_id) { return groupsOptionsModesEnum[group_id] || null; }; /* This function is going to translate the response sent from the server to the formatted data needed from the chart library */ function tsArrayToOptionsArray(tsOptionsArray, tsGroupsArray, groupsOptionsMode, tsCompare) { /* One chart per metric requested */ if (groupsOptionsMode.value == groupsOptionsModesEnum["1_chart_x_metric"].value) { return tsArrayToOptionsArrayRaw(tsOptionsArray, tsGroupsArray, groupsOptionsMode, tsCompare); } let splittedTsArray = splitTsArrayStacked(tsOptionsArray, tsGroupsArray); let DygraphOptionsStacked = tsArrayToOptionsArrayRaw(splittedTsArray.stacked.tsOptionsArray, splittedTsArray.stacked.tsGroupsArray, groupsOptionsMode, tsCompare); let DygraphOptionsNotStacked = tsArrayToOptionsArrayRaw(splittedTsArray.not_stacked.tsOptionsArray, splittedTsArray.not_stacked.tsGroupsArray, groupsOptionsMode, tsCompare); //console.log([...DygraphOptionsStacked, ...DygraphOptionsNotStacked]) return [...DygraphOptionsStacked, ...DygraphOptionsNotStacked]; } function splitTsArrayStacked(tsOptionsArray, tsGroupsArray) { let tsOptionsArrayStacked = []; let tsGroupsArrayStacked = []; let tsOptionsArrayNotStacked = []; let tsGroupsArrayNotStacked = []; tsGroupsArray.forEach((tsGroup, i) => { if (tsGroup.metric.draw_stacked == true) { tsOptionsArrayStacked.push(tsOptionsArray[i]); tsGroupsArrayStacked.push(tsGroup); } else { tsOptionsArrayNotStacked.push(tsOptionsArray[i]); tsGroupsArrayNotStacked.push(tsGroup); } }); return { stacked: { tsOptionsArray: tsOptionsArrayStacked, tsGroupsArray: tsGroupsArrayStacked, }, not_stacked: { tsOptionsArray: tsOptionsArrayNotStacked, tsGroupsArray: tsGroupsArrayNotStacked, }, }; } function tsArrayToOptionsArrayRaw(tsOptionsArray, tsGroupsArray, groupsOptionsMode, tsCompare) { let useFullName = false; if (groupsOptionsMode.value == groupsOptionsModesEnum["1_chart_x_yaxis"].value) { let tsDict = {}; tsGroupsArray.forEach((tsGroup, i) => { let yaxisId = getYaxisId(tsGroup.metric); let tsEl = { tsGroup, tsOptions: tsOptionsArray[i] }; if (tsDict[yaxisId] == null) { tsDict[yaxisId] = [tsEl]; } else { tsDict[yaxisId].push(tsEl); } }); useFullName = tsGroupsArray.length > 1 || (tsGroupsArray.length > 0 && tsGroupsArray[0].source_type.display_full_name === true); let DygraphOptionsArray = []; for (let key in tsDict) { let tsArray = tsDict[key]; let tsOptionsArray2 = tsArray.map((ts) => ts.tsOptions); let tsGroupsArray2 = tsArray.map((ts) => ts.tsGroup); let DygraphOptions = tsArrayToOptions(tsOptionsArray2, tsGroupsArray2, tsCompare, useFullName); DygraphOptionsArray.push(DygraphOptions); } return DygraphOptionsArray; } else if (groupsOptionsMode.value == groupsOptionsModesEnum["1_chart_x_metric"].value) { useFullName = tsOptionsArray.length > 1 || (tsGroupsArray.length > 0 && tsGroupsArray[0].source_type.display_full_name === true); let optionsArray = []; tsOptionsArray.forEach((tsOptions, i) => { let options = tsArrayToOptions([tsOptions], [tsGroupsArray[i]], tsCompare, useFullName); optionsArray.push(options); }); return optionsArray; } return []; } function formatSerieProperties(type) { if (type == "point") { return { fillGraph: false, customBars: false, strokeWidth: 0.0, pointSize: 2.0, } } else if (type == "line") { return { fillGraph: false, customBars: false, strokeWidth: 1.5, pointSize: 1.5, } } else if (type == "bounds") { return { fillGraph: false, strokeWidth: 1.0, pointSize: 1.5, fillAlpha: 0.5 } } else { return { fillGraph: true, customBars: false, strokeWidth: 1.0, pointSize: 1.5, fillAlpha: 0.5 } } } function formatBoundsSerie(series, series_info) { let formatted_serie = []; let color_palette = {}; let formatter = null; let serie_name = null; let serie_properties = {} series.forEach((ts_info, j) => { let scalar = 1; let ts_id = timeseriesUtils.getSerieId(ts_info); const serie = ts_info.data || []; /* Safety check */ let s_metadata = series_info.metric.timeseries[ts_id]; if (s_metadata.invert_direction == true) { scalar = -1; } if (s_metadata.type == "metric") { let name = s_metadata.label serie_name = getSerieName(name, ts_id, series_info, true); serie_properties = formatSerieProperties('bounds'); color_palette = { color: s_metadata.color, palette: 0 }; formatter = series_info.metric.measure_unit; } for (let point = 0; point < serie.length; point++) { let serie_point = serie[point] if (serie_point == null) serie_point = NaN; if (formatted_serie[point] == null) { formatted_serie[point] = [0, NaN, 0]; } if (s_metadata.type == "metric") { formatted_serie[point][1] = serie_point * scalar; } else if (s_metadata.type == "lower_bound") { formatted_serie[point][0] = serie_point * scalar; } else if (s_metadata.type == "upper_bound") { formatted_serie[point][2] = serie_point * scalar; } } }) return { serie: formatted_serie, color: color_palette, formatter: formatter, serie_name: serie_name, properties: serie_properties }; } /* Given an array of timeseries, it compacts them into a single array */ function tsArrayToOptions(tsOptionsArray, tsGroupsArray, tsCompare, useFullName) { if (tsOptionsArray.length != tsGroupsArray.length) { console.error(`Error in timeseries-utils:tsArrayToOptions: tsOptionsArray ${tsOptionsArray} different length from tsGroupsArray ${tsGroupsArray}`); return; } let formatted_serie = []; let formatters = [] let serie_labels = ["Time"]; let stacked = false; let colors = []; let colors_palette = []; let serie_properties = {}; let customBars = false; let use_full_name = (useFullName != null) ? useFullName : false; /* Go throught each serie */ tsOptionsArray.forEach((tsOptions, i) => { /* Format the data */ /* the data in Dygraphs should be formatted as follow: * { [ time_1, serie1_1, serie2_1 ], [ time_2, serie1_2, serie2_2 ] } */ if (tsGroupsArray[i].source_type.f_map_ts_options != null) { const f_map_ts_options = tsGroupsArray[i].source_type.f_map_ts_options; tsOptions = f_map_ts_options(tsOptions, tsGroupsArray[i]); } const series = tsOptions.series || []; const epoch_begin = tsOptions.metadata.epoch_begin const step = tsOptions.metadata.epoch_step const past_serie = tsOptions.additional_series const bounds = tsGroupsArray[i].metric.bounds || false; /* The serie can possibly have multiple timeseries, like for the * bytes, we have sent and rcvd, so compact them */ if (bounds == true) { /* TODO: add avg, past, ecc. timeseries to the bounds one */ customBars = true; let time = epoch_begin; const { serie, color, formatter, serie_name, properties } = formatBoundsSerie(series, tsGroupsArray[i], time, step); colors_palette.push(color); const found = formatters.find(el => el == formatter); if (found == null) formatters.push(formatter); const formatted_name = `${serie_name} ${i18n('lower_value_upper')}` serie_labels.push(formatted_name); serie_properties[formatted_name] = {} serie_properties[formatted_name] = properties; const serie_keys = Object.keys(serie); serie_keys.forEach((key, j) => { const ts_info = serie[key]; if (formatted_serie[time] == null) formatted_serie[time] = [ { value: new Date(time * 1000), name: "Time" }, { value: ts_info, name: formatted_name } ] time = time + step; }); } else { series.forEach((ts_info, j) => { const serie = ts_info.data || []; /* Safety check */ let time = epoch_begin; let ts_id = timeseriesUtils.getSerieId(ts_info); let s_metadata = tsGroupsArray[i].metric.timeseries[ts_id]; let extra_timeseries = tsGroupsArray[i].timeseries[j]; let scalar = 1; let name = s_metadata.label if (s_metadata.hidden) { return; } if (s_metadata.use_serie_name == true) { name = ts_info.name; } if (stacked == false) { stacked = tsGroupsArray[i].metric.draw_stacked; } if (s_metadata.invert_direction == true) { scalar = -1; } colors_palette.push({ color: s_metadata.color, palette: 0 }); /* Search for the formatter in the array, if not found, add it. */ const found = formatters.find(el => el == tsGroupsArray[i].metric.measure_unit); if (found == null) { formatters.push(tsGroupsArray[i].metric.measure_unit); } if (ts_info.ext_label) { name = ts_info.ext_label } /* ************************************** */ const serie_name = getSerieName(name, ts_id, tsGroupsArray[i], use_full_name) const avg_label = getSerieName(name + " Avg", ts_id, tsGroupsArray[i], use_full_name) const perc_label = getSerieName(name + " 95th Perc", ts_id, tsGroupsArray[i], use_full_name); const past_label = getSerieName(name + " " + tsCompare + " Ago", ts_id, tsGroupsArray[i], use_full_name); /* Add the serie label to the array of the labels */ serie_labels.push(serie_name); serie_properties[serie_name] = {} serie_properties[serie_name] = formatSerieProperties(ts_info.type || 'filled'); /* ************************************** */ /* Adding the extra timeseries, 30m ago, avg and 95th */ if (extra_timeseries?.avg == true) { /* Add the serie label to the array of the labels */ serie_labels.push(avg_label); serie_properties[avg_label] = {} serie_properties[avg_label] = formatSerieProperties("point"); colors_palette.push({ color: constant_serie_colors["avg"], palette: 1 }); } if (extra_timeseries?.perc_95 == true) { /* Add the serie label to the array of the labels */ serie_labels.push(perc_label); serie_properties[perc_label] = {} serie_properties[perc_label] = formatSerieProperties("point"); colors_palette.push({ color: constant_serie_colors["perc_95"], palette: 1 }); } if (extra_timeseries?.past == true) { /* Add the serie label to the array of the labels */ serie_labels.push(past_label); serie_properties[past_label] = {} serie_properties[past_label] = formatSerieProperties("line"); colors_palette.push({ color: constant_serie_colors["past"], palette: 1 }); } /* ************************************** */ for (let point = 0; point < serie.length; point++) { const serie_point = serie[point]; /* If the point is inserted for the first time, add the time before everything else */ if (formatted_serie[time] == null) { formatted_serie[time] = [{ value: new Date(time * 1000), name: "Time" }]; } /* Add the point to the array */ if (serie_point != null) { formatted_serie[time].push({ value: serie_point * scalar, name: serie_name }); } else { formatted_serie[time].push({ value: NaN, name: serie_name }); } /* Add extra series, avg, 95th and past timeseries */ if (extra_timeseries?.avg == true) { formatted_serie[time].push({ value: ts_info.statistics["average"] * scalar, name: avg_label }); } if (extra_timeseries?.perc_95 == true) { formatted_serie[time].push({ value: ts_info.statistics["95th_percentile"] * scalar * scalar, name: perc_label }); } if (extra_timeseries?.past == true) { for (const key in past_serie) { if (past_serie[key]?.series[j]?.data[point]) { formatted_serie[time].push({ value: past_serie[key]?.series[j]?.data[point] * scalar, name: past_label }); } else { formatted_serie[time].push({ value: NaN, name: past_label }); } } } /* Increase the time using the step */ time = time + step; } }) } }); /* Need to finally format the serie as requested by Dygraph, with NULL as value in case the serie has NOT THAT POINT (e.g. with a 5 minutes frequency, the user is confronting a chart with 1 minute frequency, there are 4 minutes with no existing points) */ let full_serie = []; const serie_keys = Object.keys(formatted_serie); serie_keys.forEach((key, index) => { full_serie[index] = []; /* Iterate the serie and for each label, get the value and set to null in case it does not exists */ serie_labels.forEach((label) => { let found = false; for (let j = 0; j < formatted_serie[key].length; j++) { if (formatted_serie[key][j].name == label) { full_serie[index].push(formatted_serie[key][j].value); found = true; break; } } if (found == false) { full_serie[index].push(null); } }) }); setSeriesColors(colors_palette) let chartOptions = buildChartOptions(full_serie, serie_labels, serie_properties, formatters, colors_palette, stacked, customBars); return chartOptions; } function getAxisConfiguration(formatter) { return { axisLabelFormatter: formatter, valueFormatter: function (num_or_millis, opts, seriesName, dygraph, row, col) { const serie_point = dygraph.rawData_[row][col]; let data = ''; if (typeof (serie_point) == "object") { serie_point.forEach((el) => { data = `${data} / ${formatter(el || 0)}`; }) data = data.substring(3); /* Remove the first three characters ' / ' */ } else { data = formatter(num_or_millis); } return (data); }, axisLabelWidth: 80, } } function buildChartOptions(series, labels, serie_properties, formatters, colors, stacked, customBars) { const interpolated_colors = colorsInterpolation.transformColors(colors); let is_dark_mode = document.getElementsByClassName('body dark').length > 0; let highlight_color = 'rgb(255, 255, 255)'; if (is_dark_mode) { highlight_color = 'rgb(13, 17, 23)'; } let config = { customBars: customBars, labels: labels, series: serie_properties, data: series, labelsSeparateLines: true, legend: "follow", stackedGraph: stacked, /* TODO. add stacked here */ connectSeparatedPoints: true, includeZero: true, drawPoints: true, highlightSeriesBackgroundAlpha: 0.7, highlightSeriesBackgroundColor: highlight_color, highlightSeriesOpts: { strokeWidth: 2, pointSize: 3, highlightCircleSize: 6, }, axisLabelFontSize: 12, axes: { x: {} }, colors: interpolated_colors, }; if (formatters.length > 1) { /* Multiple formatters */ /* NOTE: at most 2 formatters can be used */ config.axes.y1 = getAxisConfiguration(formatterUtils.getFormatter(formatters[0])); config.axes.y2 = getAxisConfiguration(formatterUtils.getFormatter(formatters[1])); } else if (formatters.length == 1) { /* Single formatter */ config.axes.y = getAxisConfiguration(formatterUtils.getFormatter(formatters[0])); } return config; } function getTsQuery(tsGroup, not_metric_query, enable_source_def_value_dict) { let tsQuery = tsGroup.source_type.source_def_array.map((source_def, i) => { if (enable_source_def_value_dict != null && !enable_source_def_value_dict[source_def.value]) { return null; } let source_value = tsGroup.source_array[i].value; return `${source_def.value}:${source_value}`; }).filter((s) => s != null).join(","); if (!not_metric_query && tsGroup.metric.query != null) { tsQuery = `${tsQuery},${tsGroup.metric.query}` } return tsQuery; } function getMainSourceDefIndex(tsGroup) { let source_def_array = tsGroup.source_type.source_def_array; for (let i = 0; i < source_def_array.length; i += 1) { let source_def = source_def_array[i]; if (source_def.main_source_def == true) { return i; } } return 0; } async function getTsChartsOptions(httpPrefix, epochStatus, tsCompare, timeseriesGroups, isPro) { let paramsEpochObj = { epoch_begin: epochStatus.epoch_begin, epoch_end: epochStatus.epoch_end }; let tsChartsOptions; if (!isPro) { let tsDataUrl = `${httpPrefix}/lua/rest/v2/get/timeseries/ts.lua`; let paramsUrlRequest = `ts_compare=${tsCompare}&version=4&zoom=${tsCompare}&limit=180`; let tsGroup = timeseriesGroups[0]; let main_source_index = getMainSourceDefIndex(tsGroup); let tsQuery = getTsQuery(tsGroup); let pObj = { ...paramsEpochObj, ts_query: tsQuery, ts_schema: `${tsGroup.metric.schema}`, }; if (!tsGroup.source_type.source_def_array[main_source_index].disable_tskey) { pObj.tskey = tsGroup.source_array[main_source_index].value; } let pUrlRequest = ntopng_url_manager.add_obj_to_url(pObj, paramsUrlRequest); let url = `${tsDataUrl}?${pUrlRequest}`; let tsChartOption = await ntopng_utility.http_request(url); tsChartsOptions = [tsChartOption]; } else { let paramsChart = { zoom: tsCompare, limit: 180, version: 4, ts_compare: tsCompare, }; let tsRequests = timeseriesGroups.map((tsGroup) => { let main_source_index = getMainSourceDefIndex(tsGroup); let tsQuery = getTsQuery(tsGroup); let pObj = { ts_query: tsQuery, ts_schema: `${tsGroup.metric.schema}`, }; if (!tsGroup.source_type.source_def_array[main_source_index].disable_tskey) { pObj.tskey = tsGroup.source_array[main_source_index].value; } return pObj; }); let tsDataUrlMulti = `${httpPrefix}/lua/pro/rest/v2/get/timeseries/ts_multi.lua`; let req = { ts_requests: tsRequests, ...paramsEpochObj, ...paramsChart }; let headers = { 'Content-Type': 'application/json' }; tsChartsOptions = await ntopng_utility.http_request(tsDataUrlMulti, { method: 'post', headers, body: JSON.stringify(req) }); } return tsChartsOptions; } /* Override Dygraph plugins to have a better legend */ Dygraph.Plugins.Legend.prototype.select = function (e) { var xValue = e.selectedX; var points = e.selectedPoints; var row = e.selectedRow; var legendMode = e.dygraph.getOption('legend'); if (legendMode === 'never') { this.legend_div_.style.display = 'none'; return; } var html = Dygraph.Plugins.Legend.generateLegendHTML(e.dygraph, xValue, points, this.one_em_width_, row); if (html instanceof Node && html.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { this.legend_div_.innerHTML = ''; this.legend_div_.appendChild(html); } else this.legend_div_.innerHTML = html; // must be done now so offsetWidth isn’t 0… this.legend_div_.style.display = ''; if (legendMode === 'follow') { // create floating legend div var area = e.dygraph.plotter_.area; var labelsDivWidth = this.legend_div_.offsetWidth; var yAxisLabelWidth = e.dygraph.getOptionForAxis('axisLabelWidth', 'y'); // find the closest data point by checking the currently highlighted series, // or fall back to using the first data point available var highlightSeries = e.dygraph.getHighlightSeries() var point; if (highlightSeries) { point = points.find(p => p.name === highlightSeries); if (!point) point = points[0]; } else point = points[0]; // determine floating [left, top] coordinates of the legend div // within the plotter_ area // offset 50 px to the right and down from the first selection point // 50 px is guess based on mouse cursor size const followOffsetX = e.dygraph.getNumericOption('legendFollowOffsetX'); var leftLegend = point.x * area.w + followOffsetX; // if legend floats to end of the chart area, it flips to the other // side of the selection point if ((leftLegend + labelsDivWidth + 1) > area.w) { leftLegend = leftLegend - 2 * followOffsetX - labelsDivWidth - (yAxisLabelWidth - area.x); } this.legend_div_.style.left = yAxisLabelWidth + leftLegend + "px"; document.addEventListener("mousemove", (e) => { localStorage.setItem('timeseries-mouse-top-position', e.clientY + 50 + "px") }); this.legend_div_.style.top = localStorage.getItem('timeseries-mouse-top-position'); } else if (legendMode === 'onmouseover' && this.is_generated_div_) { // synchronise this with Legend.prototype.predraw below var area = e.dygraph.plotter_.area; var labelsDivWidth = this.legend_div_.offsetWidth; this.legend_div_.style.left = area.x + area.w - labelsDivWidth - 1 + "px"; this.legend_div_.style.top = area.y + "px"; } }; const timeseriesUtils = function () { return { groupsOptionsModesEnum, tsArrayToOptions, tsArrayToOptionsArray, getGroupOptionMode, getSerieId, getSerieName, getTsChartsOptions, getTsQuery, getMainSourceDefIndex, }; }(); export default timeseriesUtils;