1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-07 01:16:28 +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 { usePlaceholderData } from '../hooks/usePlaceholderData.js';
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 { useSeriesColor } from './hooks/useSeriesColor.ts';
export const ImpactMetrics: FC = () => {
const theme = useTheme();
const getSeriesColor = useSeriesColor();
const [selectedSeries, setSelectedSeries] = useState<string>('');
const [selectedRange, setSelectedRange] = useState<
'hour' | 'day' | 'week' | 'month'
>('day');
const [beginAtZero, setBeginAtZero] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<
Record<string, string[]>
>({});
const handleSeriesChange = (series: string) => {
setSelectedSeries(series);
setSelectedLabels({}); // labels are series-specific
};
const {
metadata,
@ -32,12 +46,19 @@ export const ImpactMetrics: FC = () => {
error: metadataError,
} = useImpactMetricsMetadata();
const {
data: { start, end, data: timeSeriesData },
data: { start, end, series: timeSeriesData, labels: availableLabels },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData(
selectedSeries
? { series: selectedSeries, range: selectedRange }
? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined,
);
@ -57,7 +78,7 @@ export const ImpactMetrics: FC = () => {
}, [metadata]);
const data = useMemo(() => {
if (!timeSeriesData.length) {
if (!timeSeriesData || timeSeriesData.length === 0) {
return {
labels: [],
datasets: [
@ -70,29 +91,75 @@ export const ImpactMetrics: FC = () => {
};
}
const timestamps = timeSeriesData.map(
([epochTimestamp]) => new Date(epochTimestamp * 1000),
);
const values = timeSeriesData.map(([, value]) => value);
if (timeSeriesData.length === 1) {
const series = timeSeriesData[0];
const timestamps = series.data.map(
([epochTimestamp]) => new Date(epochTimestamp * 1000),
);
const values = series.data.map(([, value]) => value);
return {
labels: timestamps,
datasets: [
{
data: values,
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.light,
},
],
};
}, [timeSeriesData, theme]);
return {
labels: timestamps,
datasets: [
{
data: values,
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.light,
label: getSeriesLabel(series.metric),
},
],
};
} 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 isLoading = metadataLoading || dataLoading;
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
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
@ -124,13 +191,16 @@ export const ImpactMetrics: FC = () => {
>
<ImpactMetricsControls
selectedSeries={selectedSeries}
onSeriesChange={setSelectedSeries}
onSeriesChange={handleSeriesChange}
selectedRange={selectedRange}
onRangeChange={setSelectedRange}
beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero}
metricSeries={metricSeries}
loading={metadataLoading}
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
availableLabels={availableLabels}
/>
{!selectedSeries && !isLoading ? (
@ -189,7 +259,15 @@ export const ImpactMetrics: FC = () => {
},
plugins: {
legend: {
display: false,
display:
timeSeriesData &&
timeSeriesData.length > 1,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {

View File

@ -10,8 +10,10 @@ import {
Autocomplete,
TextField,
Typography,
Chip,
} from '@mui/material';
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';
export interface ImpactMetricsControlsProps {
@ -23,6 +25,9 @@ export interface ImpactMetricsControlsProps {
onBeginAtZeroChange: (beginAtZero: boolean) => void;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
}
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
@ -34,86 +39,166 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
onBeginAtZeroChange,
metricSeries,
loading = false,
}) => (
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
maxWidth: 400,
})}
>
<Typography variant='body2' color='text.secondary'>
Select a custom metric to see its value over time. This can help you
understand the impact of your feature rollout on key outcomes, such
as system performance, usage patterns or error rates.
</Typography>
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);
};
<Autocomplete
options={metricSeries}
getOptionLabel={(option) => option.name}
value={
metricSeries.find((option) => option.name === selectedSeries) ||
null
}
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography variant='caption' color='text.secondary'>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
const clearAllLabels = () => {
onLabelsChange({});
};
return (
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
maxWidth: 400,
})}
>
<Typography variant='body2' color='text.secondary'>
Select a custom metric to see its value over time. This can help
you understand the impact of your feature rollout on key
outcomes, such as system performance, usage patterns or error
rates.
</Typography>
<Autocomplete
options={metricSeries}
getOptionLabel={(option) => option.name}
value={
metricSeries.find(
(option) => option.name === selectedSeries,
) || null
}
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography
variant='caption'
color='text.secondary'
>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
</Box>
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label='Data series'
placeholder='Search for a metric…'
variant='outlined'
size='small'
/>
)}
noOptionsText='No metrics available'
sx={{ minWidth: 300 }}
/>
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
<InputLabel id='range-select-label'>Time</InputLabel>
<Select
labelId='range-select-label'
value={selectedRange}
onChange={(e) =>
onRangeChange(
e.target.value as 'hour' | 'day' | 'week' | 'month',
)
}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem>
<MenuItem value='week'>Last 7 days</MenuItem>
<MenuItem value='month'>Last 30 days</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
checked={beginAtZero}
onChange={(e) => onBeginAtZeroChange(e.target.checked)}
/>
}
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>
{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>
)}
renderInput={(params) => (
<TextField
{...params}
label='Data series'
placeholder='Search for a metric…'
variant='outlined'
size='small'
/>
)}
noOptionsText='No metrics available'
sx={{ minWidth: 300 }}
/>
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
<InputLabel id='range-select-label'>Time</InputLabel>
<Select
labelId='range-select-label'
value={selectedRange}
onChange={(e) =>
onRangeChange(
e.target.value as 'hour' | 'day' | 'week' | 'month',
)
}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem>
<MenuItem value='week'>Last 7 days</MenuItem>
<MenuItem value='month'>Last 30 days</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
checked={beginAtZero}
onChange={(e) => onBeginAtZeroChange(e.target.checked)}
/>
}
label='Begin at zero'
/>
</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';
}
};
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 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 = {
series: string;
range: 'hour' | 'day' | 'week' | 'month';
labels?: Record<string, string[]>;
};
export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
@ -17,30 +33,45 @@ export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
series: query.series,
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()}`;
};
const PATH = createPath();
const { data, refetch, loading, error } = useApiGetter<{
start?: string;
end?: string;
step?: string;
data: TimeSeriesData;
}>(
shouldFetch ? formatApiPath(PATH) : null,
shouldFetch
? () => fetcher(formatApiPath(PATH), 'Impact metrics data')
: () => Promise.resolve([]),
{
refreshInterval: 30 * 1_000,
revalidateOnFocus: true,
},
);
const { data, refetch, loading, error } =
useApiGetter<ImpactMetricsResponse>(
shouldFetch ? formatApiPath(PATH) : null,
shouldFetch
? () => fetcher(formatApiPath(PATH), 'Impact metrics data')
: () => Promise.resolve([]),
{
refreshInterval: 30 * 1_000,
revalidateOnFocus: true,
},
);
return {
data: data || {
data: [],
series: [],
labels: {},
},
refetch,
loading: shouldFetch ? loading : false,