diff --git a/frontend/src/component/insights/Insights.tsx b/frontend/src/component/insights/Insights.tsx index 76ab5b3e92..f5936916d9 100644 --- a/frontend/src/component/insights/Insights.tsx +++ b/frontend/src/component/insights/Insights.tsx @@ -7,16 +7,20 @@ import { StyledContainer } from './InsightsCharts.styles.ts'; import { LifecycleInsights } from './sections/LifecycleInsights.tsx'; import { PerformanceInsights } from './sections/PerformanceInsights.tsx'; import { UserInsights } from './sections/UserInsights.tsx'; +import { ImpactMetrics } from './impact-metrics/ImpactMetrics.tsx'; const StyledWrapper = styled('div')(({ theme }) => ({ paddingTop: theme.spacing(2), })); const NewInsights: FC = () => { + const impactMetricsEnabled = useUiFlag('impactMetrics'); + return ( + {impactMetricsEnabled ? : null} diff --git a/frontend/src/component/insights/components/LineChart/LineChart.tsx b/frontend/src/component/insights/components/LineChart/LineChart.tsx index a0262bcb72..98132bedfc 100644 --- a/frontend/src/component/insights/components/LineChart/LineChart.tsx +++ b/frontend/src/component/insights/components/LineChart/LineChart.tsx @@ -27,7 +27,10 @@ export const fillGradientPrimary = fillGradient( 'rgba(129, 122, 254, 0.12)', ); -export const NotEnoughData = () => ( +export const NotEnoughData = ({ + title = 'Not enough data', + description = 'Two or more weeks of data are needed to show a chart.', +}) => ( <> ( paddingBottom: theme.spacing(1), })} > - Not enough data - - - Two or more weeks of data are needed to show a chart. + {title} + {description} ); diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx new file mode 100644 index 0000000000..86347f5241 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx @@ -0,0 +1,294 @@ +import type { FC } from 'react'; +import { useMemo, useState } from 'react'; +import { useTheme, Box, Typography, Alert } from '@mui/material'; +import { + LineChart, + NotEnoughData, +} from '../components/LineChart/LineChart.tsx'; +import { InsightsSection } from '../sections/InsightsSection.tsx'; +import { + StyledChartContainer, + StyledWidget, + StyledWidgetStats, +} from 'component/insights/InsightsCharts.styles'; +import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { usePlaceholderData } from '../hooks/usePlaceholderData.js'; +import { ImpactMetricsControls } from './ImpactMetricsControls.tsx'; +import { + getDisplayFormat, + getSeriesLabel, + getTimeUnit, + formatLargeNumbers, +} from './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, + loading: metadataLoading, + error: metadataError, + } = useImpactMetricsMetadata(); + const { + data: { start, end, series: timeSeriesData, labels: availableLabels }, + loading: dataLoading, + error: dataError, + } = useImpactMetricsData( + selectedSeries + ? { + series: selectedSeries, + range: selectedRange, + labels: + Object.keys(selectedLabels).length > 0 + ? selectedLabels + : undefined, + } + : undefined, + ); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const metricSeries = useMemo(() => { + if (!metadata?.series) { + return []; + } + return Object.entries(metadata.series).map(([name, rest]) => ({ + name, + ...rest, + })); + }, [metadata]); + + const data = useMemo(() => { + if (!timeSeriesData || timeSeriesData.length === 0) { + return { + labels: [], + datasets: [ + { + data: [], + borderColor: theme.palette.primary.main, + backgroundColor: theme.palette.primary.light, + }, + ], + }; + } + + 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, + 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 && + (!timeSeriesData || + timeSeriesData.length === 0 || + !data.datasets.some((d) => d.data.length > 1)), + [data, isLoading, timeSeriesData], + ); + + const minTime = start + ? fromUnixTime(Number.parseInt(start, 10)) + : undefined; + const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + + const placeholder = selectedSeries ? ( + + ) : ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + return ( + + + + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + width: '100%', + })} + > + + + {!selectedSeries && !isLoading ? ( + + Select a metric series to view the chart + + ) : null} + + + + + {hasError ? ( + + Failed to load impact metrics. Please check if + Prometheus is configured and the feature flag is + enabled. + + ) : null} + + typeof value === 'number' + ? formatLargeNumbers( + value, + ) + : (value as number), + }, + }, + }, + plugins: { + legend: { + display: + timeSeriesData && + timeSeriesData.length > 1, + position: 'bottom' as const, + labels: { + usePointStyle: true, + boxWidth: 8, + padding: 12, + }, + }, + }, + animations: { + x: { duration: 0 }, + y: { duration: 0 }, + }, + } + } + cover={cover} + /> + + + + ); +}; diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx new file mode 100644 index 0000000000..3c9e9dc681 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx @@ -0,0 +1,204 @@ +import type { FC } from 'react'; +import { + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Checkbox, + Box, + 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 { + selectedSeries: string; + onSeriesChange: (series: string) => void; + selectedRange: 'hour' | 'day' | 'week' | 'month'; + onRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void; + beginAtZero: boolean; + onBeginAtZeroChange: (beginAtZero: boolean) => void; + metricSeries: (ImpactMetricsSeries & { name: string })[]; + loading?: boolean; + selectedLabels: Record; + onLabelsChange: (labels: Record) => void; + availableLabels?: ImpactMetricsLabels; +} + +export const ImpactMetricsControls: FC = ({ + selectedSeries, + onSeriesChange, + selectedRange, + onRangeChange, + beginAtZero, + onBeginAtZeroChange, + metricSeries, + 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 ( + ({ + 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 }} + /> + ), + )} + + )} + + ); +}; 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/utils.ts b/frontend/src/component/insights/impact-metrics/utils.ts new file mode 100644 index 0000000000..8c4e292d39 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/utils.ts @@ -0,0 +1,60 @@ +export const getTimeUnit = (selectedRange: string) => { + switch (selectedRange) { + case 'hour': + return 'minute'; + case 'day': + return 'hour'; + case 'week': + return 'day'; + case 'month': + return 'day'; + default: + return 'hour'; + } +}; + +export const getDisplayFormat = (selectedRange: string) => { + switch (selectedRange) { + case 'hour': + case 'day': + return 'HH:mm'; + case 'week': + case 'month': + return 'MMM dd'; + default: + 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})`; +}; + +export const formatLargeNumbers = (value: number): string => { + if (value >= 1000000) { + return `${(value / 1000000).toFixed(0)}M`; + } + if (value >= 1000) { + return `${(value / 1000).toFixed(0)}k`; + } + return value.toString(); +}; diff --git a/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts b/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts new file mode 100644 index 0000000000..930611435b --- /dev/null +++ b/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts @@ -0,0 +1,80 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; +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) => { + const shouldFetch = Boolean(query?.series && query?.range); + + const createPath = () => { + if (!query) return ''; + const params = new URLSearchParams({ + 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( + shouldFetch ? formatApiPath(PATH) : null, + shouldFetch + ? () => fetcher(formatApiPath(PATH), 'Impact metrics data') + : () => Promise.resolve([]), + { + refreshInterval: 30 * 1_000, + revalidateOnFocus: true, + }, + ); + + return { + data: data || { + series: [], + labels: {}, + }, + refetch, + loading: shouldFetch ? loading : false, + error, + }; +}; diff --git a/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts new file mode 100644 index 0000000000..a664f8fa07 --- /dev/null +++ b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts @@ -0,0 +1,26 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; +import { formatApiPath } from 'utils/formatPath'; + +export type ImpactMetricsSeries = { + type: string; + help: string; +}; + +export type ImpactMetricsMetadata = { + series: Record; +}; + +export const useImpactMetricsMetadata = () => { + const PATH = `api/admin/impact-metrics/metadata`; + const { data, refetch, loading, error } = + useApiGetter(formatApiPath(PATH), () => + fetcher(formatApiPath(PATH), 'Impact metrics metadata'), + ); + + return { + metadata: data, + refetch, + loading, + error, + }; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 4ddb37bc1c..d9d25327d6 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -90,6 +90,7 @@ export type UiFlags = { createFlagDialogCache?: boolean; healthToTechDebt?: boolean; improvedJsonDiff?: boolean; + impactMetrics?: boolean; }; export interface IVersionInfo {