From 7bed75cb9f5b1a4f3e0fc131333bcb139bd47426 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Wed, 18 Jun 2025 23:37:17 +0200 Subject: [PATCH] feat: add impact metrics feature with controls and data fetching --- frontend/src/component/insights/Insights.tsx | 6 +- .../src/component/insights/TestComponent.tsx | 82 ------- .../components/LineChart/LineChart.tsx | 11 +- .../insights/impact-metrics/ImpactMetrics.tsx | 206 ++++++++++++++++++ .../impact-metrics/ImpactMetricsControls.tsx | 94 ++++++++ .../insights/impact-metrics/time-utils.ts | 57 +++++ .../useImpactMetricsData.ts | 42 ++++ .../useImpactMetricsMetadata.ts | 22 ++ frontend/src/interfaces/uiConfig.ts | 1 + 9 files changed, 432 insertions(+), 89 deletions(-) delete mode 100644 frontend/src/component/insights/TestComponent.tsx create mode 100644 frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx create mode 100644 frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx create mode 100644 frontend/src/component/insights/impact-metrics/time-utils.ts create mode 100644 frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts create mode 100644 frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts diff --git a/frontend/src/component/insights/Insights.tsx b/frontend/src/component/insights/Insights.tsx index a6965021c0..f5936916d9 100644 --- a/frontend/src/component/insights/Insights.tsx +++ b/frontend/src/component/insights/Insights.tsx @@ -7,18 +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 { TestComponent } from './TestComponent.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/TestComponent.tsx b/frontend/src/component/insights/TestComponent.tsx deleted file mode 100644 index 38c0c4fbcc..0000000000 --- a/frontend/src/component/insights/TestComponent.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { FC } from 'react'; -import { useMemo } from 'react'; -import { useTheme } from '@mui/material'; -import { LineChart } from './components/LineChart/LineChart.tsx'; -import { data } from './data.ts'; - -type TestComponentProps = {}; - -const transformTimeSeriesData = (rawData: typeof data) => { - const firstDataset = rawData[0]; - const timeseries = firstDataset.data.values; - const timestamps = timeseries[0]; - const values = timeseries[1]; - - return { - timestamps: timestamps.map((ts) => new Date(ts)), - values, - }; -}; - -export const TestComponent: FC = () => { - const theme = useTheme(); - - const chartData = useMemo(() => { - const { timestamps, values } = transformTimeSeriesData(data); - - return { - labels: timestamps, - datasets: [ - { - data: values, - borderColor: theme.palette.primary.main, - backgroundColor: theme.palette.primary.light, - // tension: 0.1, - // pointRadius: 0, - // pointHoverRadius: 5, - }, - ], - }; - }, [theme]); - - return ( - - ); -}; 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..bf819e5162 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx @@ -0,0 +1,206 @@ +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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { usePlaceholderData } from '../hooks/usePlaceholderData.js'; +import { ImpactMetricsControls } from './ImpactMetricsControls.tsx'; +import { getDateRange, getDisplayFormat, getTimeUnit } from './time-utils.ts'; + +type ImpactMetricsProps = {}; + +export const ImpactMetrics: FC = () => { + const theme = useTheme(); + const [selectedSeries, setSelectedSeries] = useState(''); + const [selectedRange, setSelectedRange] = useState< + 'day' | 'week' | 'month' + >('day'); + const [beginAtZero, setBeginAtZero] = useState(false); + + const { + metadata, + loading: metadataLoading, + error: metadataError, + } = useImpactMetricsMetadata(); + const { + data: timeSeriesData, + loading: dataLoading, + error: dataError, + } = useImpactMetricsData( + selectedSeries + ? { series: selectedSeries, range: selectedRange } + : undefined, + ); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const data = useMemo(() => { + if (!timeSeriesData.length) { + return { + labels: [], + datasets: [ + { + data: [], + borderColor: theme.palette.primary.main, + backgroundColor: theme.palette.primary.light, + }, + ], + }; + } + + const timestamps = timeSeriesData.map( + ([epochTimestamp]) => new Date(epochTimestamp * 1000), + ); + const values = timeSeriesData.map(([, value]) => value); + + return { + labels: timestamps, + datasets: [ + { + data: values, + borderColor: theme.palette.primary.main, + backgroundColor: theme.palette.primary.light, + }, + ], + }; + }, [timeSeriesData, theme]); + + 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], + ); + + const { min: minTime, max: maxTime } = getDateRange(selectedRange); + + const placeholder = selectedSeries ? ( + + ) : ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + return ( + + + + + + Failed to load impact metrics. Please check + if Prometheus is configured and the feature + flag is enabled. + + } + /> + + + + + Select a metric series to view the chart + + } + /> + + + + + + + + + ); +}; 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..1829f63b62 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx @@ -0,0 +1,94 @@ +import type { FC } from 'react'; +import { + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Checkbox, + Box, + Autocomplete, + TextField, + Typography, +} from '@mui/material'; + +export interface ImpactMetricsControlsProps { + selectedSeries: string; + onSeriesChange: (series: string) => void; + selectedRange: 'day' | 'week' | 'month'; + onRangeChange: (range: 'day' | 'week' | 'month') => void; + beginAtZero: boolean; + onBeginAtZeroChange: (beginAtZero: boolean) => void; + metricSeries: string[]; + loading?: boolean; +} + +export const ImpactMetricsControls: FC = ({ + selectedSeries, + onSeriesChange, + selectedRange, + onRangeChange, + beginAtZero, + 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. + + + onSeriesChange(newValue || '')} + disabled={loading} + 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/time-utils.ts b/frontend/src/component/insights/impact-metrics/time-utils.ts new file mode 100644 index 0000000000..1cceb0f5d5 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/time-utils.ts @@ -0,0 +1,57 @@ +export const getTimeUnit = (selectedRange: string) => { + switch (selectedRange) { + case 'day': + return 'hour'; + case 'week': + return 'day'; + case 'month': + return 'day'; + default: + return 'hour'; + } +}; + +export const getDisplayFormat = (selectedRange: string) => { + // TODO: localized format + switch (selectedRange) { + case 'day': + return 'MMM dd HH:mm'; + case 'week': + return 'MMM dd'; + case 'month': + return 'MMM dd'; + default: + return 'MMM dd HH:mm'; + } +}; + +export const getDateRange = (selectedRange: 'day' | 'week' | 'month') => { + const now = new Date(); + const endTime = now; + + switch (selectedRange) { + case 'day': { + const startTime = new Date(now); + startTime.setHours(now.getHours() - 24, 0, 0, 0); + return { min: startTime, max: endTime }; + } + case 'week': { + const startTime = new Date(now); + startTime.setDate(now.getDate() - 7); + startTime.setHours(0, 0, 0, 0); + const endTimeWeek = new Date(now); + endTimeWeek.setHours(23, 59, 59, 999); + return { min: startTime, max: endTimeWeek }; + } + case 'month': { + const startTime = new Date(now); + startTime.setDate(now.getDate() - 30); + startTime.setHours(0, 0, 0, 0); + const endTimeMonth = new Date(now); + endTimeMonth.setHours(23, 59, 59, 999); + return { min: startTime, max: endTimeMonth }; + } + default: + return { min: undefined, max: undefined }; + } +}; 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..888d48c7d2 --- /dev/null +++ b/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts @@ -0,0 +1,42 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; +import { formatApiPath } from 'utils/formatPath'; + +export type TimeSeriesData = [number, number][]; + +export type ImpactMetricsQuery = { + series: string; + range: 'day' | 'week' | 'month'; +}; + +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, + }); + 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 || [], + 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..f0aaf003ed --- /dev/null +++ b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts @@ -0,0 +1,22 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; +import { formatApiPath } from 'utils/formatPath'; + +export type ImpactMetricsMetadata = { + series: string[]; + labels: string[]; +}; + +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 || { series: [], labels: [] }, + refetch, + loading, + error, + }; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index a96e647889..fc4bc59793 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -91,6 +91,7 @@ export type UiFlags = { createFlagDialogCache?: boolean; healthToTechDebt?: boolean; improvedJsonDiff?: boolean; + impactMetrics?: boolean; }; export interface IVersionInfo {