1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-12 13:48:35 +02:00

account for multi-series charts

This commit is contained in:
Tymoteusz Czech 2025-06-23 22:38:42 +02:00
parent b07d013d50
commit bee42187b9
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
5 changed files with 352 additions and 118 deletions

View File

@ -15,16 +15,30 @@ import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMeta
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../hooks/usePlaceholderData.js'; import { usePlaceholderData } from '../hooks/usePlaceholderData.js';
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx'; import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
import { getDisplayFormat, getTimeUnit } from './time-utils.ts'; import {
getDisplayFormat,
getSeriesLabel,
getTimeUnit,
} from './impact-metrics-utils.ts';
import { fromUnixTime } from 'date-fns'; import { fromUnixTime } from 'date-fns';
import { useSeriesColor } from './hooks/useSeriesColor.ts';
export const ImpactMetrics: FC = () => { export const ImpactMetrics: FC = () => {
const theme = useTheme(); const theme = useTheme();
const getSeriesColor = useSeriesColor();
const [selectedSeries, setSelectedSeries] = useState<string>(''); const [selectedSeries, setSelectedSeries] = useState<string>('');
const [selectedRange, setSelectedRange] = useState< const [selectedRange, setSelectedRange] = useState<
'hour' | 'day' | 'week' | 'month' 'hour' | 'day' | 'week' | 'month'
>('day'); >('day');
const [beginAtZero, setBeginAtZero] = useState(false); const [beginAtZero, setBeginAtZero] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<
Record<string, string[]>
>({});
const handleSeriesChange = (series: string) => {
setSelectedSeries(series);
setSelectedLabels({}); // labels are series-specific
};
const { const {
metadata, metadata,
@ -32,12 +46,19 @@ export const ImpactMetrics: FC = () => {
error: metadataError, error: metadataError,
} = useImpactMetricsMetadata(); } = useImpactMetricsMetadata();
const { const {
data: { start, end, data: timeSeriesData }, data: { start, end, series: timeSeriesData, labels: availableLabels },
loading: dataLoading, loading: dataLoading,
error: dataError, error: dataError,
} = useImpactMetricsData( } = useImpactMetricsData(
selectedSeries selectedSeries
? { series: selectedSeries, range: selectedRange } ? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined, : undefined,
); );
@ -57,7 +78,7 @@ export const ImpactMetrics: FC = () => {
}, [metadata]); }, [metadata]);
const data = useMemo(() => { const data = useMemo(() => {
if (!timeSeriesData.length) { if (!timeSeriesData || timeSeriesData.length === 0) {
return { return {
labels: [], labels: [],
datasets: [ datasets: [
@ -70,10 +91,12 @@ export const ImpactMetrics: FC = () => {
}; };
} }
const timestamps = timeSeriesData.map( if (timeSeriesData.length === 1) {
const series = timeSeriesData[0];
const timestamps = series.data.map(
([epochTimestamp]) => new Date(epochTimestamp * 1000), ([epochTimestamp]) => new Date(epochTimestamp * 1000),
); );
const values = timeSeriesData.map(([, value]) => value); const values = series.data.map(([, value]) => value);
return { return {
labels: timestamps, labels: timestamps,
@ -82,17 +105,61 @@ export const ImpactMetrics: FC = () => {
data: values, data: values,
borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.light, backgroundColor: theme.palette.primary.light,
label: getSeriesLabel(series.metric),
}, },
], ],
}; };
}, [timeSeriesData, theme]); } else {
const allTimestamps = new Set<number>();
timeSeriesData.forEach((series) => {
series.data.forEach(([timestamp]) => {
allTimestamps.add(timestamp);
});
});
const sortedTimestamps = Array.from(allTimestamps).sort(
(a, b) => a - b,
);
const labels = sortedTimestamps.map(
(timestamp) => new Date(timestamp * 1000),
);
const datasets = timeSeriesData.map((series) => {
const seriesLabel = getSeriesLabel(series.metric);
const color = getSeriesColor(seriesLabel);
const dataMap = new Map(series.data);
const data = sortedTimestamps.map(
(timestamp) => dataMap.get(timestamp) ?? null,
);
return {
label: seriesLabel,
data,
borderColor: color,
backgroundColor: color,
fill: false,
spanGaps: false, // Don't connect nulls
};
});
return {
labels,
datasets,
};
}
}, [timeSeriesData, theme, getSeriesColor]);
const hasError = metadataError || dataError; const hasError = metadataError || dataError;
const isLoading = metadataLoading || dataLoading; const isLoading = metadataLoading || dataLoading;
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
const notEnoughData = useMemo( const notEnoughData = useMemo(
() => !isLoading && !data.datasets.some((d) => d.data.length > 1), () =>
[data, isLoading], !isLoading &&
(!timeSeriesData ||
timeSeriesData.length === 0 ||
!data.datasets.some((d) => d.data.length > 1)),
[data, isLoading, timeSeriesData],
); );
const minTime = start const minTime = start
@ -124,13 +191,16 @@ export const ImpactMetrics: FC = () => {
> >
<ImpactMetricsControls <ImpactMetricsControls
selectedSeries={selectedSeries} selectedSeries={selectedSeries}
onSeriesChange={setSelectedSeries} onSeriesChange={handleSeriesChange}
selectedRange={selectedRange} selectedRange={selectedRange}
onRangeChange={setSelectedRange} onRangeChange={setSelectedRange}
beginAtZero={beginAtZero} beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero} onBeginAtZeroChange={setBeginAtZero}
metricSeries={metricSeries} metricSeries={metricSeries}
loading={metadataLoading} loading={metadataLoading}
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
availableLabels={availableLabels}
/> />
{!selectedSeries && !isLoading ? ( {!selectedSeries && !isLoading ? (
@ -189,7 +259,15 @@ export const ImpactMetrics: FC = () => {
}, },
plugins: { plugins: {
legend: { legend: {
display: false, display:
timeSeriesData &&
timeSeriesData.length > 1,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
}, },
}, },
animations: { animations: {

View File

@ -10,8 +10,10 @@ import {
Autocomplete, Autocomplete,
TextField, TextField,
Typography, Typography,
Chip,
} from '@mui/material'; } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { Highlighter } from 'component/common/Highlighter/Highlighter'; import { Highlighter } from 'component/common/Highlighter/Highlighter';
export interface ImpactMetricsControlsProps { export interface ImpactMetricsControlsProps {
@ -23,6 +25,9 @@ export interface ImpactMetricsControlsProps {
onBeginAtZeroChange: (beginAtZero: boolean) => void; onBeginAtZeroChange: (beginAtZero: boolean) => void;
metricSeries: (ImpactMetricsSeries & { name: string })[]; metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean; loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
} }
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
@ -34,7 +39,25 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
onBeginAtZeroChange, onBeginAtZeroChange,
metricSeries, metricSeries,
loading = false, loading = false,
}) => ( selectedLabels,
onLabelsChange,
availableLabels,
}) => {
const handleLabelChange = (labelKey: string, values: string[]) => {
const newLabels = { ...selectedLabels };
if (values.length === 0) {
delete newLabels[labelKey];
} else {
newLabels[labelKey] = values;
}
onLabelsChange(newLabels);
};
const clearAllLabels = () => {
onLabelsChange({});
};
return (
<Box <Box
sx={(theme) => ({ sx={(theme) => ({
display: 'flex', display: 'flex',
@ -44,17 +67,19 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
})} })}
> >
<Typography variant='body2' color='text.secondary'> <Typography variant='body2' color='text.secondary'>
Select a custom metric to see its value over time. This can help you Select a custom metric to see its value over time. This can help
understand the impact of your feature rollout on key outcomes, such you understand the impact of your feature rollout on key
as system performance, usage patterns or error rates. outcomes, such as system performance, usage patterns or error
rates.
</Typography> </Typography>
<Autocomplete <Autocomplete
options={metricSeries} options={metricSeries}
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
value={ value={
metricSeries.find((option) => option.name === selectedSeries) || metricSeries.find(
null (option) => option.name === selectedSeries,
) || null
} }
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')} onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
disabled={loading} disabled={loading}
@ -66,7 +91,10 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
{option.name} {option.name}
</Highlighter> </Highlighter>
</Typography> </Typography>
<Typography variant='caption' color='text.secondary'> <Typography
variant='caption'
color='text.secondary'
>
<Highlighter search={inputValue}> <Highlighter search={inputValue}>
{option.help} {option.help}
</Highlighter> </Highlighter>
@ -115,5 +143,62 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
} }
label='Begin at zero' label='Begin at zero'
/> />
{availableLabels && Object.keys(availableLabels).length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='subtitle2'>
Filter by labels
</Typography>
{Object.keys(selectedLabels).length > 0 && (
<Chip
label='Clear all'
size='small'
variant='outlined'
onClick={clearAllLabels}
/>
)}
</Box> </Box>
);
{Object.entries(availableLabels).map(
([labelKey, values]) => (
<Autocomplete
key={labelKey}
multiple
options={values}
value={selectedLabels[labelKey] || []}
onChange={(_, newValues) =>
handleLabelChange(labelKey, newValues)
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...chipProps } =
getTagProps({ index });
return (
<Chip
{...chipProps}
key={key}
label={option}
size='small'
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
label={labelKey}
placeholder='Select values...'
variant='outlined'
size='small'
/>
)}
sx={{ minWidth: 300 }}
/>
),
)}
</Box>
)}
</Box>
);
};

View File

@ -0,0 +1,17 @@
import { useTheme } from '@mui/material';
export const useSeriesColor = () => {
const theme = useTheme();
const colors = theme.palette.charts.series;
return (seriesLabel: string): string => {
let hash = 0;
for (let i = 0; i < seriesLabel.length; i++) {
const char = seriesLabel.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
const index = Math.abs(hash) % colors.length;
return colors[index];
};
};

View File

@ -25,3 +25,26 @@ export const getDisplayFormat = (selectedRange: string) => {
return 'MMM dd HH:mm'; return 'MMM dd HH:mm';
} }
}; };
export const getSeriesLabel = (metric: Record<string, string>): string => {
const { __name__, ...labels } = metric;
const labelParts = Object.entries(labels)
.filter(([key, value]) => key !== '__name__' && value)
.map(([key, value]) => `${key}=${value}`)
.join(', ');
if (!__name__ && !labelParts) {
return 'Series';
}
if (!__name__) {
return labelParts;
}
if (!labelParts) {
return __name__;
}
return `${__name__} (${labelParts})`;
};

View File

@ -3,9 +3,25 @@ import { formatApiPath } from 'utils/formatPath';
export type TimeSeriesData = [number, number][]; export type TimeSeriesData = [number, number][];
export type ImpactMetricsLabels = Record<string, string[]>;
export type ImpactMetricsSeries = {
metric: Record<string, string>;
data: TimeSeriesData;
};
export type ImpactMetricsResponse = {
start?: string;
end?: string;
step?: string;
series: ImpactMetricsSeries[];
labels?: ImpactMetricsLabels;
};
export type ImpactMetricsQuery = { export type ImpactMetricsQuery = {
series: string; series: string;
range: 'hour' | 'day' | 'week' | 'month'; range: 'hour' | 'day' | 'week' | 'month';
labels?: Record<string, string[]>;
}; };
export const useImpactMetricsData = (query?: ImpactMetricsQuery) => { export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
@ -17,17 +33,31 @@ export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
series: query.series, series: query.series,
range: query.range, range: query.range,
}); });
if (query.labels && Object.keys(query.labels).length > 0) {
// Send labels as they are - the backend will handle the formatting
const labelsParam = Object.entries(query.labels).reduce(
(acc, [key, values]) => {
if (values.length > 0) {
acc[key] = values;
}
return acc;
},
{} as Record<string, string[]>,
);
if (Object.keys(labelsParam).length > 0) {
params.append('labels', JSON.stringify(labelsParam));
}
}
return `api/admin/impact-metrics/?${params.toString()}`; return `api/admin/impact-metrics/?${params.toString()}`;
}; };
const PATH = createPath(); const PATH = createPath();
const { data, refetch, loading, error } = useApiGetter<{ const { data, refetch, loading, error } =
start?: string; useApiGetter<ImpactMetricsResponse>(
end?: string;
step?: string;
data: TimeSeriesData;
}>(
shouldFetch ? formatApiPath(PATH) : null, shouldFetch ? formatApiPath(PATH) : null,
shouldFetch shouldFetch
? () => fetcher(formatApiPath(PATH), 'Impact metrics data') ? () => fetcher(formatApiPath(PATH), 'Impact metrics data')
@ -40,7 +70,8 @@ export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
return { return {
data: data || { data: data || {
data: [], series: [],
labels: {},
}, },
refetch, refetch,
loading: shouldFetch ? loading : false, loading: shouldFetch ? loading : false,