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:
parent
b07d013d50
commit
bee42187b9
@ -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: {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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];
|
||||
};
|
||||
};
|
@ -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})`;
|
||||
};
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user