1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: personal flag metrics display (#8232)

This commit is contained in:
Mateusz Kwasniewski 2024-09-24 13:47:21 +02:00 committed by GitHub
parent d6dbdab6f1
commit 54432f3f31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 372 additions and 71 deletions

View File

@ -3,7 +3,7 @@ import GeneralSelect, {
type IGeneralSelectProps,
} from 'component/common/GeneralSelect/GeneralSelect';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useEffect } from 'react';
import { type ReactNode, useEffect } from 'react';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
const StyledTitle = styled('h2')(({ theme }) => ({
@ -17,6 +17,7 @@ const StyledTitle = styled('h2')(({ theme }) => ({
interface IFeatureMetricsHoursProps {
hoursBack: number;
setHoursBack: (value: number) => void;
label?: ReactNode;
}
export const FEATURE_METRIC_HOURS_BACK_DEFAULT = 48;
@ -24,6 +25,7 @@ export const FEATURE_METRIC_HOURS_BACK_DEFAULT = 48;
export const FeatureMetricsHours = ({
hoursBack,
setHoursBack,
label = <StyledTitle>Period</StyledTitle>,
}: IFeatureMetricsHoursProps) => {
const { trackEvent } = usePlausibleTracker();
@ -55,7 +57,7 @@ export const FeatureMetricsHours = ({
return (
<div>
<StyledTitle>Period</StyledTitle>
{label}
<GeneralSelect
name='feature-metrics-period'
id='feature-metrics-period'

View File

@ -2,7 +2,6 @@ import {
BarElement,
CategoryScale,
Chart as ChartJS,
type ChartOptions,
Legend,
LinearScale,
Title,
@ -10,10 +9,20 @@ import {
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { Bar } from 'react-chartjs-2';
import type { Theme } from '@mui/material/styles/createTheme';
import useTheme from '@mui/material/styles/useTheme';
import { useMemo } from 'react';
import { formatTickValue } from 'component/common/Chart/formatTickValue';
import { type FC, useEffect, useMemo, useState } from 'react';
import { Box, styled, Typography } from '@mui/material';
import { FeatureMetricsHours } from '../feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours';
import GeneralSelect from '../common/GeneralSelect/GeneralSelect';
import { useFeatureMetricsRaw } from 'hooks/api/getters/useFeatureMetricsRaw/useFeatureMetricsRaw';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { createChartData } from './createChartData';
import { aggregateFeatureMetrics } from '../feature/FeatureView/FeatureMetrics/aggregateFeatureMetrics';
import {
createBarChartOptions,
createPlaceholderBarChartOptions,
} from './createChartOptions';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
const defaultYes = [
45_000_000, 28_000_000, 28_000_000, 25_000_000, 50_000_000, 27_000_000,
@ -30,7 +39,7 @@ const defaultNo = [
3_000_000, 8_000_000, 2_000_000,
];
const data = {
const placeholderData = {
labels: Array.from({ length: 30 }, (_, i) => i + 1),
datasets: [
{
@ -48,73 +57,144 @@ const data = {
],
};
const createBarChartOptions = (theme: Theme): ChartOptions<'bar'> => ({
plugins: {
legend: {
position: 'bottom',
labels: {
color: theme.palette.text.primary,
pointStyle: 'circle',
usePointStyle: true,
boxHeight: 6,
padding: 15,
boxPadding: 5,
},
},
tooltip: {
enabled: false,
},
},
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
color: theme.palette.text.secondary,
},
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
color: theme.palette.text.secondary,
maxTicksLimit: 5,
callback: formatTickValue,
},
grid: {
drawBorder: false,
},
},
},
elements: {
bar: {
borderRadius: 5,
},
},
interaction: {
mode: 'index',
intersect: false,
},
});
export const PlaceholderFlagMetricsChart = () => {
const theme = useTheme();
const options = useMemo(() => {
return createBarChartOptions(theme);
return createPlaceholderBarChartOptions(theme);
}, [theme]);
return (
<Bar
data={data}
options={options}
aria-label='A bar chart with a single feature flag exposure metrics'
<>
<Typography sx={{ mb: 4 }}>Feature flag metrics</Typography>
<Bar
data={placeholderData}
options={options}
aria-label='A placeholder bar chart with a single feature flag exposure metrics'
/>
</>
);
};
const useMetricsEnvironments = (project: string, flagName: string) => {
const [environment, setEnvironment] = useState<string | null>(null);
const { feature } = useFeature(project, flagName);
const activeEnvironments = feature.environments;
const firstProductionEnvironment = activeEnvironments.find(
(env) => env.type === 'production',
);
useEffect(() => {
if (firstProductionEnvironment) {
setEnvironment(firstProductionEnvironment.name);
} else if (activeEnvironments.length > 0) {
setEnvironment(activeEnvironments[0].name);
}
}, [flagName]);
return { environment, setEnvironment, activeEnvironments };
};
const useFlagMetrics = (
flagName: string,
environment: string | null,
hoursBack: number,
) => {
const { featureMetrics: metrics = [] } = useFeatureMetricsRaw(
flagName,
hoursBack,
);
const sortedMetrics = useMemo(() => {
return [...metrics].sort((metricA, metricB) => {
return metricA.timestamp.localeCompare(metricB.timestamp);
});
}, [metrics]);
const filteredMetrics = useMemo(() => {
return aggregateFeatureMetrics(
sortedMetrics?.filter(
(metric) => environment === metric.environment,
),
).map((metric) => ({
...metric,
appName: 'all selected',
}));
}, [sortedMetrics, environment]);
const data = useMemo(() => {
return createChartData(filteredMetrics);
}, [filteredMetrics]);
const theme = useTheme();
const { locationSettings } = useLocationSettings();
const options = useMemo(() => {
return createBarChartOptions(theme, hoursBack, locationSettings);
}, [theme, hoursBack, locationSettings]);
return { data, options };
};
const EnvironmentSelect: FC<{
activeEnvironments: { name: string }[];
environment: string;
setEnvironment: (environment: string | null) => void;
}> = ({ activeEnvironments, environment, setEnvironment }) => {
return (
<GeneralSelect
name='feature-environments'
id='feature-environments'
options={activeEnvironments.map((env) => ({
key: env.name,
label: env.name,
}))}
value={String(environment)}
onChange={setEnvironment}
/>
);
};
const MetricsSelectors = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(2),
mb: theme.spacing(6),
}));
export const FlagMetricsChart: FC<{
flag: { name: string; project: string };
}> = ({ flag }) => {
const [hoursBack, setHoursBack] = useState(48);
const { environment, setEnvironment, activeEnvironments } =
useMetricsEnvironments(flag.project, flag.name);
const { data, options } = useFlagMetrics(flag.name, environment, hoursBack);
return (
<>
<MetricsSelectors>
{environment ? (
<EnvironmentSelect
environment={environment}
setEnvironment={setEnvironment}
activeEnvironments={activeEnvironments}
/>
) : null}
<FeatureMetricsHours
hoursBack={hoursBack}
setHoursBack={setHoursBack}
label={null}
/>
</MetricsSelectors>
<Bar
data={data}
options={options}
aria-label='A bar chart with a single feature flag exposure metrics'
/>
</>
);
};
ChartJS.register(
annotationPlugin,
CategoryScale,

View File

@ -12,18 +12,18 @@ import {
} from '@mui/material';
import type { Theme } from '@mui/material/styles/createTheme';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
import { type FC, useEffect, useState } from 'react';
import React, { type FC, useEffect, useState } from 'react';
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import LinkIcon from '@mui/icons-material/Link';
import { Badge } from '../common/Badge/Badge';
import { ConnectSDK, CreateFlag } from './ConnectSDK';
import { PlaceholderFlagMetricsChart } from './FlagMetricsChart';
import { WelcomeDialog } from './WelcomeDialog';
import { useLocalStorageState } from 'hooks/useLocalStorageState';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ProjectSetupComplete } from './ProjectSetupComplete';
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import type { PersonalDashboardSchema } from '../../openapi';
const ScreenExplanation = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(1),
@ -178,10 +178,12 @@ export const PersonalDashboard = () => {
const { projects, activeProject, setActiveProject } = useProjects();
const { personalDashboard } = usePersonalDashboard();
const [activeFlag, setActiveFlag] = useState<string | null>(null);
const [activeFlag, setActiveFlag] = useState<
PersonalDashboardSchema['flags'][0] | null
>(null);
useEffect(() => {
if (personalDashboard?.flags.length) {
setActiveFlag(personalDashboard.flags[0].name);
setActiveFlag(personalDashboard.flags[0]);
}
}, [JSON.stringify(personalDashboard)]);
@ -307,8 +309,8 @@ export const PersonalDashboard = () => {
<FlagListItem
key={flag.name}
flag={flag}
selected={flag.name === activeFlag}
onClick={() => setActiveFlag(flag.name)}
selected={flag.name === activeFlag?.name}
onClick={() => setActiveFlag(flag)}
/>
))}
</List>
@ -321,8 +323,11 @@ export const PersonalDashboard = () => {
</SpacedGridItem>
<SpacedGridItem item lg={8} md={1}>
<Typography sx={{ mb: 4 }}>Feature flag metrics</Typography>
<PlaceholderFlagMetricsChart />
{activeFlag ? (
<FlagMetricsChart flag={activeFlag} />
) : (
<PlaceholderFlagMetricsChart />
)}
</SpacedGridItem>
</ContentGrid>
<WelcomeDialog
@ -332,3 +337,14 @@ export const PersonalDashboard = () => {
</div>
);
};
const FlagMetricsChart = React.lazy(() =>
import('./FlagMetricsChart').then((module) => ({
default: module.FlagMetricsChart,
})),
);
const PlaceholderFlagMetricsChart = React.lazy(() =>
import('./FlagMetricsChart').then((module) => ({
default: module.PlaceholderFlagMetricsChart,
})),
);

View File

@ -0,0 +1,42 @@
import type { IFeatureMetricsRaw } from 'interfaces/featureToggle';
import type { ChartData } from 'chart.js';
import 'chartjs-adapter-date-fns';
export interface IPoint {
x: string;
y: number;
variants: Record<string, number>;
}
export const createChartData = (
metrics: IFeatureMetricsRaw[],
): ChartData<'bar', IPoint[], string> => {
const yesSeries = {
label: 'Exposed',
hoverBackgroundColor: '#A39EFF',
backgroundColor: '#A39EFF',
data: createChartPoints(metrics, (m) => m.yes),
};
const noSeries = {
label: 'Not exposed',
hoverBackgroundColor: '#D8D6FF',
backgroundColor: '#D8D6FF',
data: createChartPoints(metrics, (m) => m.no),
};
return {
datasets: [yesSeries, noSeries],
};
};
const createChartPoints = (
metrics: IFeatureMetricsRaw[],
y: (m: IFeatureMetricsRaw) => number,
): IPoint[] => {
return metrics.map((metric) => ({
x: metric.timestamp,
y: y(metric),
variants: metric.variants || {},
}));
};

View File

@ -0,0 +1,161 @@
import type { Theme } from '@mui/material/styles/createTheme';
import type { ChartOptions } from 'chart.js';
import { formatTickValue } from '../common/Chart/formatTickValue';
import type { ILocationSettings } from '../../hooks/useLocationSettings';
import type { IPoint } from '../feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartData';
import {
formatDateHM,
formatDateYMD,
formatDateYMDHM,
} from '../../utils/formatDate';
const formatVariantEntry = (
variant: [string, number],
totalExposure: number,
) => {
if (totalExposure === 0) return '';
const [key, value] = variant;
const percentage = Math.floor((Number(value) / totalExposure) * 100);
return `${value} (${percentage}%) - ${key}`;
};
export const createPlaceholderBarChartOptions = (
theme: Theme,
): ChartOptions<'bar'> => ({
plugins: {
legend: {
position: 'bottom',
labels: {
color: theme.palette.text.primary,
pointStyle: 'circle',
usePointStyle: true,
boxHeight: 6,
padding: 15,
boxPadding: 5,
},
},
tooltip: {
enabled: false,
},
},
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
color: theme.palette.text.secondary,
},
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
color: theme.palette.text.secondary,
maxTicksLimit: 5,
callback: formatTickValue,
},
grid: {
drawBorder: false,
},
},
},
elements: {
bar: {
borderRadius: 5,
},
},
interaction: {
mode: 'index',
intersect: false,
},
});
export const createBarChartOptions = (
theme: Theme,
hoursBack: number,
locationSettings: ILocationSettings,
): ChartOptions<'bar'> => {
const { plugins, responsive, elements, interaction, scales } =
createPlaceholderBarChartOptions(theme);
return {
plugins: {
legend: plugins?.legend,
tooltip: {
backgroundColor: theme.palette.background.paper,
titleColor: theme.palette.text.primary,
bodyColor: theme.palette.text.primary,
bodySpacing: 6,
padding: {
top: 20,
bottom: 20,
left: 30,
right: 30,
},
borderColor: 'rgba(0, 0, 0, 0.05)',
borderWidth: 3,
usePointStyle: true,
caretSize: 0,
boxPadding: 10,
callbacks: {
label: (item) => {
return `${item.formattedValue} - ${item.dataset.label}`;
},
afterLabel: (item) => {
const data = item.dataset.data[
item.dataIndex
] as unknown as IPoint;
if (
item.dataset.label !== 'Exposed' ||
data.variants === undefined
) {
return '';
}
const { disabled, ...actualVariants } = data.variants;
return Object.entries(actualVariants)
.map((entry) => formatVariantEntry(entry, data.y))
.join('\n');
},
title: (items) => {
return `Time: ${
hoursBack > 48
? formatDateYMDHM(
items[0].label,
locationSettings.locale,
'UTC',
)
: formatDateHM(
items[0].label,
locationSettings.locale,
)
}`;
},
},
},
},
responsive,
scales: {
x: {
...(scales ? scales.x : {}),
ticks: {
color: theme.palette.text.secondary,
callback(tickValue) {
const label = this.getLabelForValue(Number(tickValue));
return hoursBack > 48
? formatDateYMD(
label,
locationSettings.locale,
'UTC',
)
: formatDateHM(label, locationSettings.locale);
},
},
},
y: scales ? scales.y : {},
},
elements,
interaction,
};
};