1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

feat: impact metrics page with multiple charts (#10252)

This commit is contained in:
Tymoteusz Czech 2025-07-01 14:54:25 +02:00 committed by GitHub
parent 0e5080fac5
commit 3f073ad457
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1122 additions and 423 deletions

View File

@ -57,6 +57,10 @@ const BreadcrumbNav = () => {
return null;
}
if (location.pathname === '/impact-metrics') {
return null;
}
if (paths.length === 1 && paths[0] === 'projects-archive') {
// It's not possible to use `projects/archive`, because it's :projectId path
paths = ['projects', 'archive'];

View File

@ -0,0 +1,344 @@
import type { FC } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
Alert,
styled,
} from '@mui/material';
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
import {
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import { StyledChartContainer } from 'component/insights/InsightsCharts.styles';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
import type { ChartConfig } from './types.ts';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
export const StyledConfigPanel = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
[theme.breakpoints.down('lg')]: {
flex: 'none',
},
[theme.breakpoints.up('lg')]: {
flex: '0 0 400px',
},
}));
export const StyledPreviewPanel = styled(Box)(({ theme }) => ({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
[theme.breakpoints.down('lg')]: {
minHeight: '300px',
},
[theme.breakpoints.up('lg')]: {
minHeight: '400px',
},
}));
export interface ChartConfigModalProps {
open: boolean;
onClose: () => void;
onSave: (config: Omit<ChartConfig, 'id'>) => void;
initialConfig?: ChartConfig;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
}
export const ChartConfigModal: FC<ChartConfigModalProps> = ({
open,
onClose,
onSave,
initialConfig,
metricSeries,
loading = false,
}) => {
const [title, setTitle] = useState(initialConfig?.title || '');
const [selectedSeries, setSelectedSeries] = useState(
initialConfig?.selectedSeries || '',
);
const [selectedRange, setSelectedRange] = useState<
'hour' | 'day' | 'week' | 'month'
>(initialConfig?.selectedRange || 'day');
const [beginAtZero, setBeginAtZero] = useState(
initialConfig?.beginAtZero || false,
);
const [selectedLabels, setSelectedLabels] = useState<
Record<string, string[]>
>(initialConfig?.selectedLabels || {});
// Data for preview
const {
data: { start, end, series: timeSeriesData },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined,
);
// Fetch available labels for the currently selected series
const {
data: { labels: currentAvailableLabels },
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
}
: undefined,
);
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const data = useChartData(timeSeriesData);
const hasError = !!dataError;
const isLoading = 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 ? (
<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;
useEffect(() => {
if (open && initialConfig) {
setTitle(initialConfig.title || '');
setSelectedSeries(initialConfig.selectedSeries);
setSelectedRange(initialConfig.selectedRange);
setBeginAtZero(initialConfig.beginAtZero);
setSelectedLabels(initialConfig.selectedLabels);
} else if (open && !initialConfig) {
setTitle('');
setSelectedSeries('');
setSelectedRange('day');
setBeginAtZero(false);
setSelectedLabels({});
}
}, [open, initialConfig]);
const handleSave = () => {
if (!selectedSeries) return;
onSave({
title: title || undefined,
selectedSeries,
selectedRange,
beginAtZero,
selectedLabels,
});
onClose();
};
const handleSeriesChange = (series: string) => {
setSelectedSeries(series);
setSelectedLabels({});
};
const isValid = selectedSeries.length > 0;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth='lg'
fullWidth
sx={{
'& .MuiDialog-paper': {
minHeight: '600px',
maxHeight: '90vh',
},
}}
>
<DialogTitle>
{initialConfig ? 'Edit Chart' : 'Add New Chart'}
</DialogTitle>
<DialogContent>
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: { xs: 'column', lg: 'row' },
gap: theme.spacing(3),
pt: theme.spacing(1),
height: '100%',
})}
>
<StyledConfigPanel>
<TextField
label='Chart Title (optional)'
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
variant='outlined'
size='small'
/>
<ImpactMetricsControls
selectedSeries={selectedSeries}
onSeriesChange={handleSeriesChange}
selectedRange={selectedRange}
onRangeChange={setSelectedRange}
beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero}
metricSeries={metricSeries}
loading={loading}
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
availableLabels={currentAvailableLabels}
/>
</StyledConfigPanel>
{/* Preview Panel */}
<StyledPreviewPanel>
<Typography variant='h6' color='text.secondary'>
Preview
</Typography>
{!selectedSeries && !isLoading ? (
<Typography variant='body2' color='text.secondary'>
Select a metric series to view the preview
</Typography>
) : null}
<StyledChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check
if Prometheus is configured and the feature
flag is enabled.
</Alert>
) : null}
<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,
callback: (
value: unknown,
): string | number =>
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}
/>
</StyledChartContainer>
</StyledPreviewPanel>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
onClick={handleSave}
variant='contained'
disabled={!isValid}
>
{initialConfig ? 'Update' : 'Add Chart'}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@ -0,0 +1,211 @@
import type { FC } from 'react';
import { useMemo } from 'react';
import {
Box,
Typography,
IconButton,
Alert,
styled,
Paper,
} from '@mui/material';
import Edit from '@mui/icons-material/Edit';
import Delete from '@mui/icons-material/Delete';
import {
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import { StyledChartContainer } from 'component/insights/InsightsCharts.styles';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
import type { ChartConfig } from './types.ts';
export interface ChartItemProps {
config: ChartConfig;
onEdit: (config: ChartConfig) => void;
onDelete: (id: string) => void;
}
const getConfigDescription = (config: ChartConfig): string => {
const parts: string[] = [];
if (config.selectedSeries) {
parts.push(`Series: ${config.selectedSeries}`);
}
parts.push(`Time range: last ${config.selectedRange}`);
if (config.beginAtZero) {
parts.push('Begin at zero');
}
const labelCount = Object.keys(config.selectedLabels).length;
if (labelCount > 0) {
parts.push(`${labelCount} label filter${labelCount > 1 ? 's' : ''}`);
}
return parts.join(' • ');
};
const StyledHeader = styled(Typography)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(2, 3),
}));
const StyledWidget = styled(Paper)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadiusLarge}px`,
boxShadow: 'none',
display: 'flex',
flexDirection: 'column',
}));
export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => {
const {
data: { start, end, series: timeSeriesData },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData({
series: config.selectedSeries,
range: config.selectedRange,
labels:
Object.keys(config.selectedLabels).length > 0
? config.selectedLabels
: undefined,
});
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const data = useChartData(timeSeriesData);
const hasError = !!dataError;
const isLoading = dataLoading;
const shouldShowPlaceholder = 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 = (
<NotEnoughData description='Send impact metrics using Unleash SDK for this series to view the chart.' />
);
const cover = notEnoughData ? placeholder : isLoading;
return (
<StyledWidget>
<StyledHeader>
<Box>
{config.title && (
<Typography variant='h6' gutterBottom>
{config.title}
</Typography>
)}
<Typography
variant='body2'
color='text.secondary'
sx={{ mb: 1 }}
>
{getConfigDescription(config)}
</Typography>
</Box>
<Box>
<IconButton onClick={() => onEdit(config)} sx={{ mr: 1 }}>
<Edit />
</IconButton>
<IconButton onClick={() => onDelete(config.id)}>
<Delete />
</IconButton>
</Box>
</StyledHeader>
<StyledChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check if
Prometheus is configured and the feature flag is
enabled.
</Alert>
) : null}
<LineChart
data={notEnoughData || isLoading ? placeholderData : data}
overrideOptions={
shouldShowPlaceholder
? {}
: {
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(
config.selectedRange,
),
displayFormats: {
[getTimeUnit(
config.selectedRange,
)]: getDisplayFormat(
config.selectedRange,
),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero: config.beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
callback: (
value: unknown,
): string | number =>
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}
/>
</StyledChartContainer>
</StyledWidget>
);
};

View File

@ -0,0 +1,134 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Box, Typography, Button } from '@mui/material';
import Add from '@mui/icons-material/Add';
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { ChartConfigModal } from './ChartConfigModal.tsx';
import { ChartItem } from './ChartItem.tsx';
import { useUrlState } from './hooks/useUrlState.ts';
import type { ChartConfig } from './types.ts';
export const ImpactMetrics: FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
const { charts, addChart, updateChart, deleteChart } = useUrlState();
const {
metadata,
loading: metadataLoading,
error: metadataError,
} = useImpactMetricsMetadata();
const metricSeries = useMemo(() => {
if (!metadata?.series) {
return [];
}
return Object.entries(metadata.series).map(([name, rest]) => ({
name,
...rest,
}));
}, [metadata]);
const handleAddChart = () => {
setEditingChart(undefined);
setModalOpen(true);
};
const handleEditChart = (config: ChartConfig) => {
setEditingChart(config);
setModalOpen(true);
};
const handleSaveChart = (config: Omit<ChartConfig, 'id'>) => {
if (editingChart) {
updateChart(editingChart.id, config);
} else {
addChart(config);
}
setModalOpen(false);
};
const hasError = metadataError;
return (
<>
<PageHeader
title='Impact Metrics'
titleElement={
<Typography variant='h1' component='span'>
Impact Metrics
</Typography>
}
actions={
charts.length > 0 ? (
<Button
variant='contained'
startIcon={<Add />}
onClick={handleAddChart}
disabled={metadataLoading || !!hasError}
>
Add Chart
</Button>
) : null
}
/>
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '100%',
})}
>
{charts.length === 0 && !metadataLoading && !hasError ? (
<Box
sx={(theme) => ({
textAlign: 'center',
py: theme.spacing(8),
})}
>
<Typography variant='h6' gutterBottom>
No charts configured
</Typography>
<Typography
variant='body2'
color='text.secondary'
sx={{ mb: 3 }}
>
Add your first impact metrics chart to start
tracking performance
</Typography>
<Button
variant='contained'
startIcon={<Add />}
onClick={handleAddChart}
disabled={metadataLoading || !!hasError}
>
Add Chart
</Button>
</Box>
) : (
charts.map((config) => (
<ChartItem
key={config.id}
config={config}
onEdit={handleEditChart}
onDelete={deleteChart}
/>
))
)}
<ChartConfigModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveChart}
initialConfig={editingChart}
metricSeries={metricSeries}
loading={metadataLoading}
/>
</Box>
</>
);
};

View File

@ -0,0 +1,66 @@
import type { FC } from 'react';
import { Box, Typography } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { SeriesSelector } from './components/SeriesSelector.tsx';
import { RangeSelector, type TimeRange } from './components/RangeSelector.tsx';
import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx';
import { LabelsFilter } from './components/LabelsFilter.tsx';
export type ImpactMetricsControlsProps = {
selectedSeries: string;
onSeriesChange: (series: string) => void;
selectedRange: TimeRange;
onRangeChange: (range: TimeRange) => void;
beginAtZero: boolean;
onBeginAtZeroChange: (beginAtZero: boolean) => void;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
};
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
props,
) => (
<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>
<SeriesSelector
value={props.selectedSeries}
onChange={props.onSeriesChange}
options={props.metricSeries}
loading={props.loading}
/>
<RangeSelector
value={props.selectedRange}
onChange={props.onRangeChange}
/>
<BeginAtZeroToggle
value={props.beginAtZero}
onChange={props.onBeginAtZeroChange}
/>
{props.availableLabels && (
<LabelsFilter
selectedLabels={props.selectedLabels}
onChange={props.onLabelsChange}
availableLabels={props.availableLabels}
/>
)}
</Box>
);

View File

@ -0,0 +1,22 @@
import type { FC } from 'react';
import { FormControlLabel, Checkbox } from '@mui/material';
export type BeginAtZeroToggleProps = {
value: boolean;
onChange: (beginAtZero: boolean) => void;
};
export const BeginAtZeroToggle: FC<BeginAtZeroToggleProps> = ({
value,
onChange,
}) => (
<FormControlLabel
control={
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
/>
}
label='Begin at zero'
/>
);

View File

@ -0,0 +1,86 @@
import type { FC } from 'react';
import { Box, Autocomplete, TextField, Typography, Chip } from '@mui/material';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
export type LabelsFilterProps = {
selectedLabels: Record<string, string[]>;
onChange: (labels: Record<string, string[]>) => void;
availableLabels: ImpactMetricsLabels;
};
export const LabelsFilter: FC<LabelsFilterProps> = ({
selectedLabels,
onChange,
availableLabels,
}) => {
const handleLabelChange = (labelKey: string, values: string[]) => {
const newLabels = { ...selectedLabels };
if (values.length === 0) {
delete newLabels[labelKey];
} else {
newLabels[labelKey] = values;
}
onChange(newLabels);
};
const clearAllLabels = () => {
onChange({});
};
if (!availableLabels || Object.keys(availableLabels).length === 0) {
return null;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='subtitle2'>Filter by labels</Typography>
{Object.keys(selectedLabels).length > 0 && (
<Chip
label='Clear all'
size='small'
variant='outlined'
onClick={clearAllLabels}
/>
)}
</Box>
{Object.entries(availableLabels).map(([labelKey, values]) => (
<Autocomplete
key={labelKey}
multiple
options={values}
value={selectedLabels[labelKey] || []}
onChange={(_, newValues) =>
handleLabelChange(labelKey, newValues)
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...chipProps } = getTagProps({
index,
});
return (
<Chip
{...chipProps}
key={key}
label={option}
size='small'
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
label={labelKey}
placeholder='Select values...'
variant='outlined'
size='small'
/>
)}
sx={{ minWidth: 300 }}
/>
))}
</Box>
);
};

View File

@ -0,0 +1,26 @@
import type { FC } from 'react';
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
export type TimeRange = 'hour' | 'day' | 'week' | 'month';
export type RangeSelectorProps = {
value: TimeRange;
onChange: (range: TimeRange) => void;
};
export const RangeSelector: FC<RangeSelectorProps> = ({ value, onChange }) => (
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
<InputLabel id='range-select-label'>Time</InputLabel>
<Select
labelId='range-select-label'
value={value}
onChange={(e) => onChange(e.target.value as TimeRange)}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem>
<MenuItem value='week'>Last 7 days</MenuItem>
<MenuItem value='month'>Last 30 days</MenuItem>
</Select>
</FormControl>
);

View File

@ -0,0 +1,55 @@
import type { FC } from 'react';
import { Autocomplete, TextField, Typography, Box } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
type SeriesOption = ImpactMetricsSeries & { name: string };
export type SeriesSelectorProps = {
value: string;
onChange: (series: string) => void;
options: SeriesOption[];
loading?: boolean;
};
export const SeriesSelector: FC<SeriesSelectorProps> = ({
value,
onChange,
options,
loading = false,
}) => (
<Autocomplete
options={options}
getOptionLabel={(option) => option.name}
value={options.find((option) => option.name === value) || null}
onChange={(_, newValue) => onChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography variant='caption' color='text.secondary'>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
</Box>
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label='Data series'
placeholder='Search for a metric…'
variant='outlined'
size='small'
/>
)}
noOptionsText='No metrics available'
sx={{ minWidth: 300 }}
/>
);

View File

@ -0,0 +1,22 @@
import type { FC } from 'react';
import { styled } from '@mui/material';
import { ImpactMetrics } from './ImpactMetrics.tsx';
const StyledWrapper = styled('div')(({ theme }) => ({
paddingTop: theme.spacing(2),
}));
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(4),
paddingBottom: theme.spacing(4),
}));
export const ImpactMetricsPage: FC = () => (
<StyledWrapper>
<StyledContainer>
<ImpactMetrics />
</StyledContainer>
</StyledWrapper>
);

View File

@ -0,0 +1,108 @@
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocalStorageState } from 'hooks/useLocalStorageState';
import type { ChartConfig, ImpactMetricsState } from '../types.ts';
const encodeState = (
state: ImpactMetricsState | null | undefined,
): string | undefined =>
state && state.charts.length > 0 ? btoa(JSON.stringify(state)) : undefined;
const decodeState = (
value: string | (string | null)[] | null | undefined,
): ImpactMetricsState | null => {
if (typeof value !== 'string') return null;
try {
return JSON.parse(atob(value));
} catch {
return null;
}
};
export const useUrlState = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [storedState, setStoredState] =
useLocalStorageState<ImpactMetricsState>('impact-metrics-state', {
charts: [],
});
const urlState = decodeState(searchParams.get('data'));
const currentState = urlState || storedState;
useEffect(() => {
if (urlState) {
setStoredState(urlState);
} else if (storedState.charts.length > 0) {
const encoded = encodeState(storedState);
if (encoded) {
setSearchParams(
(prev) => {
prev.set('data', encoded);
return prev;
},
{ replace: true },
);
}
}
}, [urlState, storedState.charts.length, setStoredState, setSearchParams]);
const updateState = useCallback(
(newState: ImpactMetricsState) => {
setStoredState(newState);
setSearchParams(
(prev) => {
const encoded = encodeState(newState);
if (encoded) {
prev.set('data', encoded);
} else {
prev.delete('data');
}
return prev;
},
{ replace: true },
);
},
[setStoredState, setSearchParams],
);
const addChart = useCallback(
(config: Omit<ChartConfig, 'id'>) => {
const newChart: ChartConfig = {
...config,
id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
updateState({
charts: [...currentState.charts, newChart],
});
},
[currentState.charts, updateState],
);
const updateChart = useCallback(
(id: string, updates: Partial<ChartConfig>) => {
updateState({
charts: currentState.charts.map((chart) =>
chart.id === id ? { ...chart, ...updates } : chart,
),
});
},
[currentState.charts, updateState],
);
const deleteChart = useCallback(
(id: string) => {
updateState({
charts: currentState.charts.filter((chart) => chart.id !== id),
});
},
[currentState.charts, updateState],
);
return {
charts: currentState.charts,
addChart,
updateChart,
deleteChart,
};
};

View File

@ -0,0 +1,12 @@
export type ChartConfig = {
id: string;
selectedSeries: string;
selectedRange: 'hour' | 'day' | 'week' | 'month';
beginAtZero: boolean;
selectedLabels: Record<string, string[]>;
title?: string;
};
export type ImpactMetricsState = {
charts: ChartConfig[];
};

View File

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

View File

@ -1,216 +0,0 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { 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, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
export const ImpactMetrics: FC = () => {
const [selectedSeries, setSelectedSeries] = useState<string>('');
const [selectedRange, setSelectedRange] = useState<
'hour' | 'day' | 'week' | 'month'
>('day');
const [beginAtZero, setBeginAtZero] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<
Record<string, string[]>
>({});
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 = useChartData(timeSeriesData);
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 ? (
<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={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '100%',
})}
>
<ImpactMetricsControls
selectedSeries={selectedSeries}
onSeriesChange={handleSeriesChange}
selectedRange={selectedRange}
onRangeChange={setSelectedRange}
beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero}
metricSeries={metricSeries}
loading={metadataLoading}
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
availableLabels={availableLabels}
/>
{!selectedSeries && !isLoading ? (
<Typography variant='body2' color='text.secondary'>
Select a metric series to view the chart
</Typography>
) : null}
</Box>
</StyledWidgetStats>
<StyledChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check if
Prometheus is configured and the feature flag is
enabled.
</Alert>
) : null}
<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,
callback: (
value: unknown,
): string | number =>
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}
/>
</StyledChartContainer>
</StyledWidget>
</InsightsSection>
);
};

View File

@ -1,203 +0,0 @@
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<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
}
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
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 (
<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}
getOptionLabel={(option) => option.name}
value={
metricSeries.find(
(option) => option.name === selectedSeries,
) || null
}
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography
variant='caption'
color='text.secondary'
>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
</Box>
</Box>
)}
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 'hour' | 'day' | 'week' | 'month',
)
}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<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'
/>
{availableLabels && Object.keys(availableLabels).length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='subtitle2'>
Filter by labels
</Typography>
{Object.keys(selectedLabels).length > 0 && (
<Chip
label='Clear all'
size='small'
variant='outlined'
onClick={clearAllLabels}
/>
)}
</Box>
{Object.entries(availableLabels).map(
([labelKey, values]) => (
<Autocomplete
key={labelKey}
multiple
options={values}
value={selectedLabels[labelKey] || []}
onChange={(_, newValues) =>
handleLabelChange(labelKey, newValues)
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...chipProps } =
getTagProps({ index });
return (
<Chip
{...chipProps}
key={key}
label={option}
size='small'
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
label={labelKey}
placeholder='Select values...'
variant='outlined'
size='small'
/>
)}
sx={{ minWidth: 300 }}
/>
),
)}
</Box>
) : null}
</Box>
);
};

View File

@ -15,6 +15,7 @@ import GroupsIcon from '@mui/icons-material/GroupsOutlined';
import RoleIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
import SettingsIcon from '@mui/icons-material/Settings';
import InsightsIcon from '@mui/icons-material/Insights';
import ImpactMetricsIcon from '@mui/icons-material/TrendingUpOutlined';
import ApiAccessIcon from '@mui/icons-material/KeyOutlined';
import SingleSignOnIcon from '@mui/icons-material/AssignmentOutlined';
import NetworkIcon from '@mui/icons-material/HubOutlined';
@ -44,6 +45,7 @@ const icons: Record<
> = {
'/search': FlagOutlinedIcon,
'/insights': InsightsIcon,
'/impact-metrics': ImpactMetricsIcon,
'/applications': ApplicationsIcon,
'/context': ContextFieldsIcon,
'/feature-toggle-type': FlagTypesIcon,

View File

@ -12,6 +12,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useNewAdminMenu } from 'hooks/useNewAdminMenu';
import { AdminMenuNavigation } from '../AdminMenu/AdminNavigationItems.tsx';
import { ConfigurationAccordion } from './ConfigurationAccordion.tsx';
import { useRoutes } from './useRoutes.ts';
import { useUiFlag } from 'hooks/useUiFlag.ts';
export const OtherLinksList = () => {
const { uiConfig } = useUiConfig();
@ -38,6 +40,7 @@ export const PrimaryNavigationList: FC<{
onClick: (activeItem: string) => void;
activeItem?: string;
}> = ({ mode, setMode, onClick, activeItem }) => {
const { routes } = useRoutes();
const PrimaryListItem = ({
href,
text,
@ -53,6 +56,7 @@ export const PrimaryNavigationList: FC<{
);
const { isOss } = useUiConfig();
const impactMetricsEnabled = useUiFlag('impactMetrics');
return (
<List>
@ -63,6 +67,9 @@ export const PrimaryNavigationList: FC<{
{!isOss() ? (
<PrimaryListItem href='/insights' text='Analytics' />
) : null}
{!isOss() && impactMetricsEnabled ? (
<PrimaryListItem href='/impact-metrics' text='Impact Metrics' />
) : null}
<ConfigurationAccordion
mode={mode}
setMode={setMode}

View File

@ -135,6 +135,17 @@ exports[`returns all baseRoutes 1`] = `
"title": "Analytics",
"type": "protected",
},
{
"component": [Function],
"enterprise": true,
"flag": "impactMetrics",
"menu": {
"primary": true,
},
"path": "/impact-metrics",
"title": "Impact metrics",
"type": "protected",
},
{
"component": [Function],
"menu": {},

View File

@ -42,6 +42,7 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte
import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
import { Insights } from '../insights/Insights.jsx';
import { ImpactMetricsPage } from '../impact-metrics/ImpactMetricsPage.tsx';
import { FeedbackList } from '../feedbackNew/FeedbackList.jsx';
import { Application } from 'component/application/Application';
import { Signals } from 'component/signals/Signals';
@ -159,6 +160,17 @@ export const routes: IRoute[] = [
enterprise: true,
},
// Impact Metrics
{
path: '/impact-metrics',
title: 'Impact metrics',
component: ImpactMetricsPage,
type: 'protected',
menu: { primary: true },
enterprise: true,
flag: 'impactMetrics',
},
// Applications
{
path: '/applications/:name/*',