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:
parent
d6dbdab6f1
commit
54432f3f31
@ -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'
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
})),
|
||||
);
|
||||
|
42
frontend/src/component/personalDashboard/createChartData.ts
Normal file
42
frontend/src/component/personalDashboard/createChartData.ts
Normal 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 || {},
|
||||
}));
|
||||
};
|
161
frontend/src/component/personalDashboard/createChartOptions.ts
Normal file
161
frontend/src/component/personalDashboard/createChartOptions.ts
Normal 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,
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue
Block a user