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 { 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 />
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
    </>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
    healthToTechDebt?: boolean;
 | 
			
		||||
    improvedJsonDiff?: boolean;
 | 
			
		||||
    impactMetrics?: boolean;
 | 
			
		||||
    crDiffView?: boolean;
 | 
			
		||||
    changeRequestApproverEmails?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user