1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-21 13:47:39 +02:00
This commit is contained in:
Tymoteusz Czech 2025-06-24 10:15:23 +02:00 committed by GitHub
commit 8a06b8cfdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 692 additions and 5 deletions

View File

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

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,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<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 = 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<number>();
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 ? (
<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

@ -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<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>
)}
</Box>
);
};

View File

@ -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];
};
};

View File

@ -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, string>): 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();
};

View File

@ -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<string, string[]>;
export type ImpactMetricsSeries = {
metric: Record<string, string>;
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<string, string[]>;
};
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<string, string[]>,
);
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<ImpactMetricsResponse>(
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,
};
};

View File

@ -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<string, ImpactMetricsSeries>;
};
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,
refetch,
loading,
error,
};
};

View File

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