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 { 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,29 +91,75 @@ export const ImpactMetrics: FC = () => {
}; };
} }
const timestamps = timeSeriesData.map( if (timeSeriesData.length === 1) {
([epochTimestamp]) => new Date(epochTimestamp * 1000), const series = timeSeriesData[0];
); const timestamps = series.data.map(
const values = timeSeriesData.map(([, value]) => value); ([epochTimestamp]) => new Date(epochTimestamp * 1000),
);
const values = series.data.map(([, value]) => value);
return { return {
labels: timestamps, labels: timestamps,
datasets: [ datasets: [
{ {
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,86 +39,166 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
onBeginAtZeroChange, onBeginAtZeroChange,
metricSeries, metricSeries,
loading = false, loading = false,
}) => ( selectedLabels,
<Box onLabelsChange,
sx={(theme) => ({ availableLabels,
display: 'flex', }) => {
flexDirection: 'column', const handleLabelChange = (labelKey: string, values: string[]) => {
gap: theme.spacing(3), const newLabels = { ...selectedLabels };
maxWidth: 400, if (values.length === 0) {
})} delete newLabels[labelKey];
> } else {
<Typography variant='body2' color='text.secondary'> newLabels[labelKey] = values;
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 onLabelsChange(newLabels);
as system performance, usage patterns or error rates. };
</Typography>
<Autocomplete const clearAllLabels = () => {
options={metricSeries} onLabelsChange({});
getOptionLabel={(option) => option.name} };
value={
metricSeries.find((option) => option.name === selectedSeries) || return (
null <Box
} sx={(theme) => ({
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')} display: 'flex',
disabled={loading} flexDirection: 'column',
renderOption={(props, option, { inputValue }) => ( gap: theme.spacing(3),
<Box component='li' {...props}> maxWidth: 400,
<Box sx={{ display: 'flex', flexDirection: 'column' }}> })}
<Typography variant='body2'> >
<Highlighter search={inputValue}> <Typography variant='body2' color='text.secondary'>
{option.name} Select a custom metric to see its value over time. This can help
</Highlighter> you understand the impact of your feature rollout on key
</Typography> outcomes, such as system performance, usage patterns or error
<Typography variant='caption' color='text.secondary'> rates.
<Highlighter search={inputValue}> </Typography>
{option.help}
</Highlighter> <Autocomplete
</Typography> 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> </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> </Box>
)} )}
renderInput={(params) => ( </Box>
<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>
);

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,30 +33,45 @@ 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; shouldFetch ? formatApiPath(PATH) : null,
step?: string; shouldFetch
data: TimeSeriesData; ? () => fetcher(formatApiPath(PATH), 'Impact metrics data')
}>( : () => Promise.resolve([]),
shouldFetch ? formatApiPath(PATH) : null, {
shouldFetch refreshInterval: 30 * 1_000,
? () => fetcher(formatApiPath(PATH), 'Impact metrics data') revalidateOnFocus: true,
: () => Promise.resolve([]), },
{ );
refreshInterval: 30 * 1_000,
revalidateOnFocus: true,
},
);
return { return {
data: data || { data: data || {
data: [], series: [],
labels: {},
}, },
refetch, refetch,
loading: shouldFetch ? loading : false, loading: shouldFetch ? loading : false,