1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: add impact metrics feature with controls and data fetching

This commit is contained in:
Tymoteusz Czech 2025-06-18 23:37:17 +02:00
parent 1d06e30a28
commit 7bed75cb9f
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
9 changed files with 432 additions and 89 deletions

View File

@ -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 (
<StyledWrapper>
<InsightsHeader />
<StyledContainer>
<TestComponent />
{impactMetricsEnabled ? <ImpactMetrics /> : null}
<LifecycleInsights />
<PerformanceInsights />
<UserInsights />

View File

@ -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<TestComponentProps> = () => {
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 (
<LineChart
data={chartData}
overrideOptions={{
scales: {
x: {
type: 'time',
time: {
unit: 'hour',
displayFormats: {
hour: 'MMM dd HH:mm',
},
tooltipFormat: 'PPpp',
},
// title: {
// display: true,
// text: 'Time',
// },
},
y: {
beginAtZero: false,
title: {
display: true,
text: 'User Count',
},
ticks: {
precision: 0,
},
},
},
plugins: {
legend: {
display: false,
// display: true,
// position: 'top',
},
},
}}
/>
);
};

View File

@ -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.',
}) => (
<>
<Typography
variant='body1'
@ -36,10 +39,8 @@ export const NotEnoughData = () => (
paddingBottom: theme.spacing(1),
})}
>
Not enough data
</Typography>
<Typography variant='body2'>
Two or more weeks of data are needed to show a chart.
{title}
</Typography>
<Typography variant='body2'>{description}</Typography>
</>
);

View File

@ -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<ImpactMetricsProps> = () => {
const theme = useTheme();
const [selectedSeries, setSelectedSeries] = useState<string>('');
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 ? (
<NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' />
) : (
<NotEnoughData
title='Select a metric series to view the chart.'
description=''
/>
);
const cover = notEnoughData ? placeholder : isLoading;
return (
<InsightsSection title='Impact metrics'>
<StyledWidget>
<StyledWidgetStats>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%',
}}
>
<ConditionallyRender
condition={Boolean(hasError)}
show={
<Alert severity='error'>
Failed to load impact metrics. Please check
if Prometheus is configured and the feature
flag is enabled.
</Alert>
}
/>
<ImpactMetricsControls
selectedSeries={selectedSeries}
onSeriesChange={setSelectedSeries}
selectedRange={selectedRange}
onRangeChange={setSelectedRange}
beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero}
metricSeries={metadata.series}
loading={metadataLoading}
/>
<ConditionallyRender
condition={!selectedSeries && !isLoading}
show={
<Typography
variant='body2'
color='text.secondary'
>
Select a metric series to view the chart
</Typography>
}
/>
</Box>
</StyledWidgetStats>
<StyledChartContainer>
<LineChart
data={
notEnoughData || isLoading ? placeholderData : data
}
overrideOptions={
shouldShowPlaceholder
? {}
: {
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(
selectedRange,
),
displayFormats: {
[getTimeUnit(
selectedRange,
)]:
getDisplayFormat(
selectedRange,
),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
},
},
},
plugins: {
legend: {
display: false,
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
}
}
cover={cover}
/>
</StyledChartContainer>
</StyledWidget>
</InsightsSection>
);
};

View File

@ -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<ImpactMetricsControlsProps> = ({
selectedSeries,
onSeriesChange,
selectedRange,
onRangeChange,
beginAtZero,
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>
<Autocomplete
options={metricSeries}
value={selectedSeries || null}
onChange={(_, newValue) => onSeriesChange(newValue || '')}
disabled={loading}
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 'day' | 'week' | 'month')
}
label='Time Range'
>
<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,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 };
}
};

View File

@ -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<TimeSeriesData>(
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,
};
};

View File

@ -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<ImpactMetricsMetadata>(formatApiPath(PATH), () =>
fetcher(formatApiPath(PATH), 'Impact metrics metadata'),
);
return {
metadata: data || { series: [], labels: [] },
refetch,
loading,
error,
};
};

View File

@ -91,6 +91,7 @@ export type UiFlags = {
createFlagDialogCache?: boolean;
healthToTechDebt?: boolean;
improvedJsonDiff?: boolean;
impactMetrics?: boolean;
};
export interface IVersionInfo {