diff --git a/timeserieschart/schemas/migrate/migrate.cue b/timeserieschart/schemas/migrate/migrate.cue index a6924949..b06f1ccf 100644 --- a/timeserieschart/schemas/migrate/migrate.cue +++ b/timeserieschart/schemas/migrate/migrate.cue @@ -106,6 +106,17 @@ spec: { yAxis: max: #max } + #logBase: [// switch + if (*#panel.fieldConfig.defaults.logBase | null) != null { + #panel.fieldConfig.defaults.logBase + }, + null, + ][0] + if #logBase != null { + yAxis: logBase: #logBase + yAxis: type: "log" + } + #yAxisLabel: *#panel.fieldConfig.defaults.custom.axisLabel | null if #yAxisLabel != null if len(#yAxisLabel) > 0 { yAxis: label: #yAxisLabel diff --git a/timeserieschart/schemas/migrate/tests/basic-4/expected.json b/timeserieschart/schemas/migrate/tests/basic-4/expected.json new file mode 100644 index 00000000..f6924bf9 --- /dev/null +++ b/timeserieschart/schemas/migrate/tests/basic-4/expected.json @@ -0,0 +1,38 @@ +{ + "kind": "TimeSeriesChart", + "spec": { + "legend": { + "mode": "list", + "position": "bottom", + "values": [] + }, + "visual": { + "areaOpacity": 0.1, + "connectNulls": false, + "display": "line", + "lineWidth": 1, + "lineStyle": "solid" + }, + "yAxis": { + "format": { + "unit": "bytes" + }, + "label": "Amount of endpoints succesfully monitored", + "min": 0, + "logBase": 2, + "type": "log" + }, + "thresholds": { + "steps": [ + { + "color": "#73bf69", + "value": 0 + }, + { + "color": "#f2cc0c", + "value": 80 + } + ] + } + } +} diff --git a/timeserieschart/schemas/migrate/tests/basic-4/input.json b/timeserieschart/schemas/migrate/tests/basic-4/input.json new file mode 100644 index 00000000..9cd48f7b --- /dev/null +++ b/timeserieschart/schemas/migrate/tests/basic-4/input.json @@ -0,0 +1,100 @@ +{ + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "description": "Current raw capacity of DataNode in bytes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "Amount of endpoints succesfully monitored", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "lineStyle": { + "fill": "solid" + }, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "links": [], + "mappings": [], + "min": 0, + "logBase": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "semi-dark-yellow", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 27, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.3.0-pre", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "avg without (instance) (hadoop_hdfs_datanode_capacity{bar_stack_id=\"$bar_stack_id\", _target=~\"$datanode\"})", + "interval": "", + "legendFormat": "{{_target}}", + "range": true, + "refId": "A" + } + ], + "title": "datanode_capacity", + "type": "timeseries" +} diff --git a/timeserieschart/schemas/time-series.cue b/timeserieschart/schemas/time-series.cue index b8878aff..4253bb69 100644 --- a/timeserieschart/schemas/time-series.cue +++ b/timeserieschart/schemas/time-series.cue @@ -56,6 +56,7 @@ spec: close({ if min != _|_ && max != _|_ { max: >=min } + logBase?: 2 | 10 } #querySettings: [...{ @@ -67,4 +68,4 @@ spec: close({ }] #lineStyle: "solid" | "dashed" | "dotted" -#areaOpacity: number & >=0 & <=1 // transparency level from 0 (transparent) to 1 (opaque) \ No newline at end of file +#areaOpacity: number & >=0 & <=1 // transparency level from 0 (transparent) to 1 (opaque) diff --git a/timeserieschart/sdk/go/time-series.go b/timeserieschart/sdk/go/time-series.go index e3046935..e1cf71a7 100644 --- a/timeserieschart/sdk/go/time-series.go +++ b/timeserieschart/sdk/go/time-series.go @@ -96,11 +96,12 @@ type Visual struct { } type YAxis struct { - Show bool `json:"show,omitempty" yaml:"show,omitempty"` - Label string `json:"label,omitempty" yaml:"label,omitempty"` - Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"` - Min float64 `json:"min,omitempty" yaml:"min,omitempty"` - Max float64 `json:"max,omitempty" yaml:"max,omitempty"` + Show bool `json:"show,omitempty" yaml:"show,omitempty"` + Label string `json:"label,omitempty" yaml:"label,omitempty"` + Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"` + Min float64 `json:"min,omitempty" yaml:"min,omitempty"` + Max float64 `json:"max,omitempty" yaml:"max,omitempty"` + LogBase uint `json:"logBase,omitempty" yaml:"logBase,omitempty"` } type PluginSpec struct { diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index 010d014d..ca920073 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -54,6 +54,7 @@ import { DEFAULT_VISUAL, THRESHOLD_PLOT_INTERVAL, QuerySettingsOptions, + LOG_BASE, } from './time-series-chart-model'; import { getTimeSeries, @@ -121,10 +122,13 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement return merge({}, DEFAULT_VISUAL, props.spec.visual); }, [props.spec.visual]); + // Use the logBase from yAxis options, defaulting to 'none' if not set + const useLogarithmicBase: LOG_BASE = yAxis?.logBase ?? 'none'; + // convert Perses dashboard format to be ECharts compatible const echartsYAxis = useMemo(() => { - return convertPanelYAxis(yAxis); - }, [yAxis]); + return convertPanelYAxis(yAxis, useLogarithmicBase); + }, [yAxis, useLogarithmicBase]); const [selectedLegendItems, setSelectedLegendItems] = useState('ALL'); const [legendSorting, setLegendSorting] = useState['sorting']>(); diff --git a/timeserieschart/src/YAxisOptionsEditor.tsx b/timeserieschart/src/YAxisOptionsEditor.tsx index 88c8b47c..773004cd 100644 --- a/timeserieschart/src/YAxisOptionsEditor.tsx +++ b/timeserieschart/src/YAxisOptionsEditor.tsx @@ -12,9 +12,10 @@ // limitations under the License. import { Switch, TextField } from '@mui/material'; -import { OptionsEditorControl, OptionsEditorGroup, FormatControls } from '@perses-dev/components'; + +import { OptionsEditorControl, OptionsEditorGroup, FormatControls, SettingsAutocomplete } from '@perses-dev/components'; import { ReactElement } from 'react'; -import { DEFAULT_FORMAT, DEFAULT_Y_AXIS, TimeSeriesChartYAxisOptions, Y_AXIS_CONFIG } from './time-series-chart-model'; +import { DEFAULT_FORMAT, DEFAULT_Y_AXIS, TimeSeriesChartYAxisOptions, Y_AXIS_CONFIG, LOG_BASE_LABEL, LOG_BASE, LOG_BASE_OPTIONS, LOG_BASE_CONFIG, LOG_VALID_BASES} from './time-series-chart-model'; export interface YAxisOptionsEditorProps { value: TimeSeriesChartYAxisOptions; @@ -22,6 +23,9 @@ export interface YAxisOptionsEditorProps { } export function YAxisOptionsEditor({ value, onChange }: YAxisOptionsEditorProps): ReactElement { + + const logBase = LOG_BASE_CONFIG[LOG_VALID_BASES[value.logBase ?? 'none']]; + return ( + { + const updatedValue: TimeSeriesChartYAxisOptions = { + ...value, + logBase: newValue.log + }; + onChange(updatedValue); + }} + disabled={value === undefined} + disableClearable + > + } + /> = { + 'none': { label: 'None', log: 'none' }, + 'log2': { label: '2', log: 2 }, + 'log10': { label: '10', log: 10 }, +}; + +// Options array for SettingsAutocomplete +export const LOG_BASE_OPTIONS = Object.entries(LOG_BASE_CONFIG).map(([id, config]) => ({ + id: id as LOG_BASE_LABEL, + ...config, +})); + +// Reverse lookup map from LOG_BASE value to LOG_BASE_LABEL +export const LOG_VALID_BASES: Record = Object.fromEntries( + Object.entries(LOG_BASE_CONFIG).map(([label, config]) => [config.log, label]) +) as Record; + // Both of these constants help produce a value that is LESS THAN the initial value. // For positive values, we multiply by a number less than 1 to get this outcome. // For negative values, we multiply to a number greater than 1 to get this outcome. diff --git a/timeserieschart/src/utils/data-transform.test.ts b/timeserieschart/src/utils/data-transform.test.ts index 995ef9b9..32a537d2 100644 --- a/timeserieschart/src/utils/data-transform.test.ts +++ b/timeserieschart/src/utils/data-transform.test.ts @@ -54,7 +54,7 @@ describe('convertPanelYAxis', () => { min: 0.1, max: 1, }; - const echartsAxis = convertPanelYAxis(persesAxis); + const echartsAxis = convertPanelYAxis(persesAxis, 'none'); // Axis label is handled outside of echarts since it is built with a custom React component. expect(echartsAxis).toEqual({ show: true, diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index 10165deb..654d5cc2 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -40,6 +40,7 @@ import { TimeSeriesChartVisualOptions, TimeSeriesChartYAxisOptions, LineStyleType, + LOG_BASE, } from '../time-series-chart-model'; export type RunningQueriesState = ReturnType; @@ -228,22 +229,29 @@ function findMax(data: LegacyTimeSeries[] | TimeSeries[]): number { } /** - * Converts Perses panel yAxis from dashboard spec to ECharts supported yAxis options + * Converts Perses panel yAxis from dashboard spec to ECharts supported yAxis options. + * Handles both linear and logarithmic scales with appropriate min/max calculations. */ -export function convertPanelYAxis(inputAxis: TimeSeriesChartYAxisOptions = {}): YAXisComponentOption { - const yAxis: YAXisComponentOption = { - show: true, - axisLabel: { - show: inputAxis?.show ?? DEFAULT_Y_AXIS.show, - }, - min: inputAxis?.min, - max: inputAxis?.max, - }; - - // Set the y-axis minimum relative to the data - if (inputAxis?.min === undefined) { +export function convertPanelYAxis( + inputAxis: TimeSeriesChartYAxisOptions = {}, + useLogarithmicBase: LOG_BASE +): YAXisComponentOption { + // Determine the appropriate min value based on scale type and user input + let minValue: YAXisComponentOption['min']; + + if (inputAxis?.min !== undefined) { + // User explicitly set a min value - use it for both linear and log scales + minValue = inputAxis.min; + } else if (useLogarithmicBase !== 'none') { + // For logarithmic scales without explicit min: + // Let ECharts auto-calculate the range based on data to avoid issues with + // function-based calculations which can result in improper ranges (e.g., 1-10) + minValue = undefined; + } else { + // For linear scales without explicit min: + // Use dynamic calculation with padding for better visualization // https://echarts.apache.org/en/option.html#yAxis.min - yAxis.min = (value): number => { + minValue = (value): number => { if (value.min >= 0 && value.min <= 1) { // Helps with PercentDecimal units, or datasets that return 0 or 1 booleans return 0; @@ -259,6 +267,25 @@ export function convertPanelYAxis(inputAxis: TimeSeriesChartYAxisOptions = {}): }; } + // Build the yAxis configuration + const yAxis: YAXisComponentOption = { + show: true, + axisLabel: { + show: inputAxis?.show ?? DEFAULT_Y_AXIS.show, + }, + min: minValue, + max: inputAxis?.max, + }; + + // Apply logarithmic scale settings if requested + if (useLogarithmicBase !== 'none') { + return { + ...yAxis, + type: 'log', + logBase: useLogarithmicBase, + }; + } + return yAxis; }