mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Feat: impact metrics fronted (#10182)
This commit is contained in:
		
							parent
							
								
									c5ddcdbc3c
								
							
						
					
					
						commit
						39cdc170f2
					
				@ -7,16 +7,20 @@ import { StyledContainer } from './InsightsCharts.styles.ts';
 | 
				
			|||||||
import { LifecycleInsights } from './sections/LifecycleInsights.tsx';
 | 
					import { LifecycleInsights } from './sections/LifecycleInsights.tsx';
 | 
				
			||||||
import { PerformanceInsights } from './sections/PerformanceInsights.tsx';
 | 
					import { PerformanceInsights } from './sections/PerformanceInsights.tsx';
 | 
				
			||||||
import { UserInsights } from './sections/UserInsights.tsx';
 | 
					import { UserInsights } from './sections/UserInsights.tsx';
 | 
				
			||||||
 | 
					import { ImpactMetrics } from './impact-metrics/ImpactMetrics.tsx';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const StyledWrapper = styled('div')(({ theme }) => ({
 | 
					const StyledWrapper = styled('div')(({ theme }) => ({
 | 
				
			||||||
    paddingTop: theme.spacing(2),
 | 
					    paddingTop: theme.spacing(2),
 | 
				
			||||||
}));
 | 
					}));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const NewInsights: FC = () => {
 | 
					const NewInsights: FC = () => {
 | 
				
			||||||
 | 
					    const impactMetricsEnabled = useUiFlag('impactMetrics');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <StyledWrapper>
 | 
					        <StyledWrapper>
 | 
				
			||||||
            <InsightsHeader />
 | 
					            <InsightsHeader />
 | 
				
			||||||
            <StyledContainer>
 | 
					            <StyledContainer>
 | 
				
			||||||
 | 
					                {impactMetricsEnabled ? <ImpactMetrics /> : null}
 | 
				
			||||||
                <LifecycleInsights />
 | 
					                <LifecycleInsights />
 | 
				
			||||||
                <PerformanceInsights />
 | 
					                <PerformanceInsights />
 | 
				
			||||||
                <UserInsights />
 | 
					                <UserInsights />
 | 
				
			||||||
 | 
				
			|||||||
@ -27,7 +27,10 @@ export const fillGradientPrimary = fillGradient(
 | 
				
			|||||||
    'rgba(129, 122, 254, 0.12)',
 | 
					    '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
 | 
					        <Typography
 | 
				
			||||||
            variant='body1'
 | 
					            variant='body1'
 | 
				
			||||||
@ -36,10 +39,8 @@ export const NotEnoughData = () => (
 | 
				
			|||||||
                paddingBottom: theme.spacing(1),
 | 
					                paddingBottom: theme.spacing(1),
 | 
				
			||||||
            })}
 | 
					            })}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
            Not enough data
 | 
					            {title}
 | 
				
			||||||
        </Typography>
 | 
					 | 
				
			||||||
        <Typography variant='body2'>
 | 
					 | 
				
			||||||
            Two or more weeks of data are needed to show a chart.
 | 
					 | 
				
			||||||
        </Typography>
 | 
					        </Typography>
 | 
				
			||||||
 | 
					        <Typography variant='body2'>{description}</Typography>
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										216
									
								
								frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,216 @@
 | 
				
			|||||||
 | 
					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>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -0,0 +1,203 @@
 | 
				
			|||||||
 | 
					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>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -0,0 +1,85 @@
 | 
				
			|||||||
 | 
					import { useMemo } from 'react';
 | 
				
			||||||
 | 
					import { useTheme } from '@mui/material';
 | 
				
			||||||
 | 
					import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
				
			||||||
 | 
					import { useSeriesColor } from './useSeriesColor.ts';
 | 
				
			||||||
 | 
					import { getSeriesLabel } from '../utils.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const useChartData = (
 | 
				
			||||||
 | 
					    timeSeriesData: ImpactMetricsSeries[] | undefined,
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					    const theme = useTheme();
 | 
				
			||||||
 | 
					    const getSeriesColor = useSeriesColor();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return 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,
 | 
				
			||||||
 | 
					                };
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return {
 | 
				
			||||||
 | 
					                labels,
 | 
				
			||||||
 | 
					                datasets,
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }, [timeSeriesData, theme, getSeriesColor]);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -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];
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										60
									
								
								frontend/src/component/insights/impact-metrics/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								frontend/src/component/insights/impact-metrics/utils.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -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,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -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,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -90,6 +90,7 @@ export type UiFlags = {
 | 
				
			|||||||
    createFlagDialogCache?: boolean;
 | 
					    createFlagDialogCache?: boolean;
 | 
				
			||||||
    healthToTechDebt?: boolean;
 | 
					    healthToTechDebt?: boolean;
 | 
				
			||||||
    improvedJsonDiff?: boolean;
 | 
					    improvedJsonDiff?: boolean;
 | 
				
			||||||
 | 
					    impactMetrics?: boolean;
 | 
				
			||||||
    crDiffView?: boolean;
 | 
					    crDiffView?: boolean;
 | 
				
			||||||
    changeRequestApproverEmails?: boolean;
 | 
					    changeRequestApproverEmails?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user