diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx index 65398a22fe..035879152d 100644 --- a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx +++ b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx @@ -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(''); const [selectedRange, setSelectedRange] = useState< 'hour' | 'day' | 'week' | 'month' >('day'); const [beginAtZero, setBeginAtZero] = useState(false); + const [selectedLabels, setSelectedLabels] = useState< + Record + >({}); + + 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(); + 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 = () => { > {!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: { diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx index 3c782ee0b8..3c9e9dc681 100644 --- a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx +++ b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx @@ -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; + onLabelsChange: (labels: Record) => void; + availableLabels?: ImpactMetricsLabels; } export const ImpactMetricsControls: FC = ({ @@ -34,86 +39,166 @@ export const ImpactMetricsControls: FC = ({ onBeginAtZeroChange, metricSeries, loading = false, -}) => ( - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(3), - maxWidth: 400, - })} - > - - 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. - + 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); + }; - option.name} - value={ - metricSeries.find((option) => option.name === selectedSeries) || - null - } - onChange={(_, newValue) => onSeriesChange(newValue?.name || '')} - disabled={loading} - renderOption={(props, option, { inputValue }) => ( - - - - - {option.name} - - - - - {option.help} - - + const clearAllLabels = () => { + onLabelsChange({}); + }; + + return ( + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + maxWidth: 400, + })} + > + + 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. + + + option.name} + value={ + metricSeries.find( + (option) => option.name === selectedSeries, + ) || null + } + onChange={(_, newValue) => onSeriesChange(newValue?.name || '')} + disabled={loading} + renderOption={(props, option, { inputValue }) => ( + + + + + {option.name} + + + + + {option.help} + + + + )} + renderInput={(params) => ( + + )} + noOptionsText='No metrics available' + sx={{ minWidth: 300 }} + /> + + + Time + + + + onBeginAtZeroChange(e.target.checked)} + /> + } + label='Begin at zero' + /> + + {availableLabels && Object.keys(availableLabels).length > 0 && ( + + + + Filter by labels + + {Object.keys(selectedLabels).length > 0 && ( + + )} + + + {Object.entries(availableLabels).map( + ([labelKey, values]) => ( + + handleLabelChange(labelKey, newValues) + } + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...chipProps } = + getTagProps({ index }); + return ( + + ); + }) + } + renderInput={(params) => ( + + )} + sx={{ minWidth: 300 }} + /> + ), + )} )} - renderInput={(params) => ( - - )} - noOptionsText='No metrics available' - sx={{ minWidth: 300 }} - /> - - - Time - - - - onBeginAtZeroChange(e.target.checked)} - /> - } - label='Begin at zero' - /> - -); + + ); +}; diff --git a/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts b/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts new file mode 100644 index 0000000000..a8a0f3f6e5 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts @@ -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]; + }; +}; diff --git a/frontend/src/component/insights/impact-metrics/time-utils.ts b/frontend/src/component/insights/impact-metrics/impact-metrics-utils.ts similarity index 54% rename from frontend/src/component/insights/impact-metrics/time-utils.ts rename to frontend/src/component/insights/impact-metrics/impact-metrics-utils.ts index 5d0e814427..2f62e26676 100644 --- a/frontend/src/component/insights/impact-metrics/time-utils.ts +++ b/frontend/src/component/insights/impact-metrics/impact-metrics-utils.ts @@ -25,3 +25,26 @@ export const getDisplayFormat = (selectedRange: string) => { return 'MMM dd HH:mm'; } }; + +export const getSeriesLabel = (metric: Record): 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})`; +}; diff --git a/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts b/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts index fcda63f519..930611435b 100644 --- a/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts +++ b/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts @@ -3,9 +3,25 @@ import { formatApiPath } from 'utils/formatPath'; export type TimeSeriesData = [number, number][]; +export type ImpactMetricsLabels = Record; + +export type ImpactMetricsSeries = { + metric: Record; + 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; }; 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, + ); + + 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( + 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,