Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions statchart/schemas/migrate/migrate.cue
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,13 @@ kind: "StatChart"
spec: {
calculation: *commonMigrate.#mapping.calc[#panel.options.reduceOptions.calcs[0]] | commonMigrate.#defaultCalc // only consider [0] here as Perses's GaugeChart doesn't support individual calcs

// metricLabel
#textMode: *#panel.options.textMode | null
if #textMode == "name" && (*#panel.targets[0].legendFormat | null) != null {
// /!\ best effort logic
// - if legendFormat contains a more complex expression than {{label}}, the result will be broken (but manually fixable afterwards still)
// - Perses's metricLabel is a single setting at panel level, hence the [0], so the result wont fit in case of multiple queries using different legendFormat
metricLabel: strings.Trim(#panel.targets[0].legendFormat, "{}")
}
// /!\ here too using [0] thus not perfect, even though the field getting remapped is unique to the whole panel in that case
if #textMode == "auto" && (*#panel.targets[0].format | null) == "table" && (*#panel.options.reduceOptions.fields | null) != null {
metricLabel: strings.Trim(#panel.options.reduceOptions.fields, "/^$")
// textMode - map directly from Grafana's BigValueTextMode enum
textMode: *#panel.options.textMode | "auto"

// metricLabel - map from reduceOptions.fields for field selection
#fields: *#panel.options.reduceOptions.fields | null
if #fields != null && #fields != "" {
metricLabel: strings.Trim(#fields, "/^$")
}

// format
Expand Down
1 change: 1 addition & 0 deletions statchart/schemas/migrate/tests/basic/expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"kind": "StatChart",
"spec": {
"calculation": "last-number",
"textMode": "auto",
"format": {
"decimalPlaces": 2,
"unit": "bytes/sec"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"kind": "StatChart",
"spec": {
"metricLabel": "version",
"textMode": "name",
"format": {
"unit": "decimal"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"kind": "StatChart",
"spec": {
"textMode": "auto",
"metricLabel": "version",
"format": {
"unit": "decimal"
Expand Down
1 change: 1 addition & 0 deletions statchart/schemas/migrate/tests/mappings/expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"kind": "StatChart",
"spec": {
"calculation": "last",
"textMode": "auto",
"mappings": [
{
"kind": "Value",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"kind": "StatChart",
"spec": {
"calculation": "mean",
"textMode": "auto",
"format": {
"unit": "decimal"
},
Expand Down
1 change: 1 addition & 0 deletions statchart/schemas/stat.cue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
kind: "StatChart"
spec: close({
calculation: common.#calculation
textMode?: "auto" | "value" | "name" | "none" | "value_and_name"
metricLabel?: common.#metricLabel
format?: common.#format
thresholds?: common.#thresholds
Expand Down
3 changes: 2 additions & 1 deletion statchart/src/StatChartBase.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ describe('StatChart', () => {
};

const mockStatData: StatChartData = {
calculatedValue: 7.72931659687181,
numericValue: 7.72931659687181,
displayValue: 7.72931659687181,
color: '#1976d2',
seriesData: {
name: '(((count(count(node_cpu_seconds_total{job="example"}) by (cpu))',
Expand Down
40 changes: 28 additions & 12 deletions statchart/src/StatChartBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { EChart, FontSizeOption, GraphSeries, useChartsTheme } from '@perses-dev
import chroma from 'chroma-js';
import { useOptimalFontSize } from './utils/calculate-font-size';
import { formatStatChartValue } from './utils/format-stat-chart-value';
import { ColorMode } from './stat-chart-model';
import { ColorMode, TextMode } from './stat-chart-model';

use([EChartsLineChart, GridComponent, DatasetComponent, TitleComponent, TooltipComponent, CanvasRenderer]);

Expand All @@ -36,7 +36,9 @@ const BLACK_COLOR_CODE = '#000000';

export interface StatChartData {
color: string;
calculatedValue?: string | number | null;
numericValue?: number | null;
displayValue?: string | number | null;
displayName?: string;
seriesData?: GraphSeries;
}

Expand All @@ -46,9 +48,11 @@ export interface StatChartProps {
data: StatChartData;
format?: FormatOptions;
sparkline?: LineSeriesOption;
showSeriesName?: boolean;
valueFontSize?: FontSizeOption;
colorMode?: ColorMode;
textMode?: TextMode;
isMultiSeries?: boolean;
legendMode?: 'auto' | 'on' | 'off';
}

export const StatChartBase: FC<StatChartProps> = (props) => {
Expand All @@ -58,10 +62,12 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
data,
data: { color },
sparkline,
showSeriesName,
format,
valueFontSize,
colorMode,
textMode = 'auto',
isMultiSeries = false,
legendMode = 'auto',
} = props;

const {
Expand All @@ -71,20 +77,30 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
},
} = useTheme();
const chartsTheme = useChartsTheme();
const formattedValue = formatStatChartValue(data.calculatedValue, format);
const formattedValue = formatStatChartValue(data.displayValue, format);
const containerPadding = chartsTheme.container.padding.default;

// calculate series name font size and height
// Determine if top text should be shown
// Respect explicit legendMode choice, otherwise let textMode decide
const shouldShowTopText = (() => {
if (legendMode === 'off') return false;
if (legendMode === 'on') return true;

// legendMode='auto': let textMode decide
return textMode === 'value_and_name' || (textMode === 'auto' && isMultiSeries);
})();

// calculate top text font size and height
let seriesNameFontSize = useOptimalFontSize({
text: data?.seriesData?.name ?? '',
text: data.displayName ?? '',
fontWeight: SERIES_NAME_FONT_WEIGHT,
width,
height: height * 0.125, // assume series name will take 12.5% of available height
lineHeight: LINE_HEIGHT,
maxSize: SERIES_NAME_MAX_FONT_SIZE,
});

const seriesNameHeight = showSeriesName ? seriesNameFontSize * LINE_HEIGHT + containerPadding : 0;
const seriesNameHeight = shouldShowTopText ? seriesNameFontSize * LINE_HEIGHT + containerPadding : 0;

// calculate value font size and height
const availableWidth = width - containerPadding * 2;
Expand Down Expand Up @@ -199,7 +215,7 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
}, [colorMode, containerPadding, optimalValueFontSize, formattedValue, color, paletteMode]);

const seriesName = useMemo((): ReactNode | null => {
if (!showSeriesName) return null;
if (!shouldShowTopText || !data.displayName) return null;

let textColor = '';

Expand All @@ -219,10 +235,10 @@ export const StatChartBase: FC<StatChartProps> = (props) => {

return (
<SeriesName padding={containerPadding} fontSize={seriesNameFontSize} color={textColor}>
{data.seriesData?.name}
{data.displayName}
</SeriesName>
);
}, [colorMode, showSeriesName, secondary, color, containerPadding, seriesNameFontSize, data?.seriesData?.name]);
}, [colorMode, shouldShowTopText, secondary, color, containerPadding, seriesNameFontSize, data.displayName]);

return (
<Box
Expand All @@ -237,7 +253,7 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
}}
>
{seriesName}
{styledFormattedValue}
{data.displayValue !== undefined && textMode !== 'none' && styledFormattedValue}
{sparkline && (
<EChart
sx={{
Expand Down
32 changes: 32 additions & 0 deletions statchart/src/StatChartOptionsEditorSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
ShowLegendLabelItem,
StatChartOptions,
StatChartOptionsEditorProps,
TEXT_MODE_LABELS,
TextModeLabelItem,
} from './stat-chart-model';

const DEFAULT_FORMAT: FormatOptions = { unit: 'percent-decimal' };
Expand Down Expand Up @@ -80,6 +82,17 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
[onChange, value]
);

const handleTextModeChange = useCallback(
(_: unknown, newTextMode: TextModeLabelItem): void => {
onChange(
produce(value, (draft: StatChartOptions) => {
draft.textMode = newTextMode.id;
})
);
},
[onChange, value]
);

const handleUnitChange: FormatControlsProps['onChange'] = (newFormat) => {
onChange(
produce(value, (draft: StatChartOptions) => {
Expand Down Expand Up @@ -164,6 +177,24 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
);
}, [value.colorMode, handleColorModeChange]);

const selectTextMode = useMemo((): ReactElement => {
return (
<OptionsEditorControl
label="Text mode"
control={
<SettingsAutocomplete
onChange={handleTextModeChange}
options={TEXT_MODE_LABELS}
disableClearable
value={
TEXT_MODE_LABELS.find((i) => i.id === value.textMode) ?? TEXT_MODE_LABELS.find((i) => i.id === 'auto')!
}
/>
}
/>
);
}, [value.textMode, handleTextModeChange]);

return (
<OptionsEditorGrid>
<OptionsEditorColumn>
Expand All @@ -175,6 +206,7 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
/>
<FormatControls value={format} onChange={handleUnitChange} />
<CalculationSelector value={value.calculation} onChange={handleCalculationChange} />
{selectTextMode}
<MetricLabelInput value={value.metricLabel} onChange={handleMetricLabelChange} />
<FontSizeSelector value={value.valueFontSize} onChange={handleFontSizeChange} />
{selectColorMode}
Expand Down
94 changes: 81 additions & 13 deletions statchart/src/StatChartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,12 @@ export type StatChartPanelProps = PanelProps<StatChartOptions, TimeSeriesData>;
export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
const { spec, contentDimensions, queryResults } = props;

const { format, sparkline, valueFontSize: valueFontSize, colorMode } = spec;
const { format, sparkline, valueFontSize: valueFontSize, colorMode, textMode, legendMode } = spec;
const chartsTheme = useChartsTheme();
const statChartData = useStatChartData(queryResults, spec, chartsTheme);

const isMultiSeries = statChartData.length > 1;

// Handle three-state showLegend: 'on' | 'off' | 'auto' (or undefined for backward compatibility)
const shouldShowLegend = spec.legendMode === 'on' ? true : spec.legendMode === 'off' ? false : isMultiSeries;

if (!contentDimensions) return null;

// Calculates chart width
Expand Down Expand Up @@ -75,9 +72,11 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
data={series}
format={format}
sparkline={sparklineConfig}
showSeriesName={shouldShowLegend}
valueFontSize={valueFontSize}
colorMode={colorMode}
textMode={textMode}
isMultiSeries={isMultiSeries}
legendMode={legendMode}
/>
);
})
Expand All @@ -94,33 +93,102 @@ const useStatChartData = (
chartsTheme: PersesChartsTheme
): StatChartData[] => {
return useMemo(() => {
const { calculation, mappings, metricLabel } = spec;
const { calculation, mappings, metricLabel, textMode } = spec;

const statChartData: StatChartData[] = [];

// Count total series to determine if multi-series
const totalSeries = queryResults.reduce((sum, result) => sum + result.data.series.length, 0);
const isMultiSeries = totalSeries > 1;

for (const result of queryResults) {
for (const seriesData of result.data.series) {
const calculatedValue = calculateValue(calculation, seriesData);
const numericValue = calculateValue(calculation, seriesData);

// get label metric value
// Get label if metricLabel is set
const labelValue = getLabelValue(metricLabel, seriesData.labels);

// get actual value to display
const displayValue = getValueOrLabel(calculatedValue, mappings, labelValue);
// Use formattedName (with legend format applied) or fallback to raw name
const formattedSeriesName = seriesData.formattedName ?? seriesData.name;

// Determine what to display based on textMode
const { displayValue, displayName } = getDisplayContent({
textMode: textMode ?? 'auto',
numericValue,
labelValue,
seriesName: formattedSeriesName,
isMultiSeries,
mappings,
});

const color = getStatChartColor(chartsTheme, spec, calculatedValue);
// Color based on numeric value (always)
const color = getStatChartColor(chartsTheme, spec, numericValue);

const series: GraphSeries = {
name: seriesData.formattedName ?? '',
name: formattedSeriesName,
values: seriesData.values,
};

statChartData.push({ calculatedValue: displayValue, seriesData: series, color });
statChartData.push({
numericValue,
displayValue,
displayName,
seriesData: series,
color,
});
}
}
return statChartData;
}, [queryResults, spec, chartsTheme]);
};

const getDisplayContent = ({
textMode,
numericValue,
labelValue,
seriesName,
mappings,
isMultiSeries,
}: {
textMode: string;
numericValue?: number | null;
labelValue?: string;
seriesName: string;
mappings?: ValueMapping[];
isMultiSeries: boolean;
}): { displayValue?: string | number | null; displayName?: string } => {
const formattedValue = getValueOrLabel(numericValue, mappings, labelValue);

switch (textMode) {
case 'value':
return {
displayValue: formattedValue,
displayName: undefined,
};
case 'name':
return {
displayValue: seriesName,
displayName: undefined,
};
case 'value_and_name':
return {
displayValue: formattedValue,
displayName: seriesName,
};
case 'none':
return {
displayValue: undefined,
displayName: undefined,
};
case 'auto':
default:
return {
displayValue: formattedValue,
displayName: isMultiSeries ? seriesName : undefined,
};
}
};

const getValueOrLabel = (
value?: number | null,
mappings?: ValueMapping[],
Expand Down
Loading