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:
parent
1d06e30a28
commit
7bed75cb9f
@ -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 />
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
206
frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx
Normal file
206
frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
57
frontend/src/component/insights/impact-metrics/time-utils.ts
Normal file
57
frontend/src/component/insights/impact-metrics/time-utils.ts
Normal 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 };
|
||||
}
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -91,6 +91,7 @@ export type UiFlags = {
|
||||
createFlagDialogCache?: boolean;
|
||||
healthToTechDebt?: boolean;
|
||||
improvedJsonDiff?: boolean;
|
||||
impactMetrics?: boolean;
|
||||
};
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
Loading…
Reference in New Issue
Block a user